User-Defined Constraints — Syntax Reference

gtopt supports user-defined linear constraints that are added directly to the LP formulation. Constraints are expressed in an AMPL-inspired syntax that references power system elements and their LP variables, with optional domain restrictions over scenarios, stages, and blocks.


Table of Contents

  1. Quick Start
  2. Syntax Overview
  3. Element Types and Attributes
  4. Element Identification
  5. Aggregation with sum()
  6. Domain Specifications
  7. Comments
  8. Examples
  9. External Constraint Files
  10. Formal Grammar (BNF)
  11. Comparison with AMPL
  12. Best Practices
  13. See Also

1. Quick Start

Add a user_constraint_array to the system section of your JSON case file:

{
  "system": {
    "bus_array": [...],
    "generator_array": [...],
    "user_constraint_array": [
      {
        "uid": 1,
        "name": "gen_pair_limit",
        "expression": "generator('G1').generation + generator('G2').generation <= 300"
      }
    ]
  }
}

This adds a constraint to the LP: the sum of generation from G1 and G2 must not exceed 300 MW in every scenario, stage, and block.


2. Syntax Overview

A constraint expression has three parts:

<linear_expression> <operator> <rhs> [, for(<domain>)]
PartDescriptionExample
Linear expressionSum of coefficient * element.attribute terms‘2 * generator('G1’).generation - demand('D1').load\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Operator \ilinebr </td> <td class="markdownTableBodyNone"> Comparison:<=,>=, or=\ilinebr </td> <td class="markdownTableBodyNone"><=\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> RHS \ilinebr </td> <td class="markdownTableBodyNone"> Right-hand side: number or another linear expression \ilinebr </td> <td class="markdownTableBodyNone">300\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Domain (optional) \ilinebr </td> <td class="markdownTableBodyNone"> Index restriction:for(stage in ..., block in ...)\ilinebr </td> <td class="markdownTableBodyNone">for(stage in {1,2,3}, block in 1..24)`

Range constraints

Range constraints bound an expression from both sides:

100 <= generator('G1').generation <= 500

This creates a single LP row with both lower and upper bounds.

Element references

Elements are referenced by type and identifier (name or UID):

generator('TORO').generation    -- by name
generator('uid:23').generation  -- by UID

3. Element Types and Attributes

Element typeAttributesLP variable meaning
generatorgenerationGenerator power output (MW)
generatorcostGeneration cost contribution ($/h)
demandloadServed demand (MW)
demandfailUnserved demand / load curtailment (MW)
lineflowActive power flow on transmission line (MW)
lineflowpPositive-direction power flow (MW)
lineflownNegative-direction power flow (MW)
linelosspPositive-direction line losses (MW)
linelossnNegative-direction line losses (MW)
batteryenergyBattery state of energy (MWh); scaled by energy_scale
batterychargeBattery charging power (MW)
batterydischargeBattery discharging power (MW)
batteryspillBattery energy spillway / curtailment (MW); also accepts drain
converterchargeConverter charging power (MW)
converterdischargeConverter discharging power (MW)
reservoirvolumeReservoir water volume (dam³); also accepts energy; scaled by energy_scale
reservoirextractionWater extraction from reservoir (m³/s); scaled by energy_scale
reservoirspillReservoir spillway discharge (m³/s); also accepts drain; scaled by energy_scale
busthetaVoltage angle at bus (radians); also accepts angle; scaled by 1/scale_theta
waterwayflowWater flow through waterway (m³/s)
turbinegenerationTurbine power output (MW)
junctiondrainJunction drain/spill variable (m³/s)
flowflowWater discharge into junction (m³/s); also accepts discharge
filtrationflowFiltration flow variable (m³/s); also accepts filtration
reserve_provisionupUp-reserve provision variable (MW reserved up); also accepts uprovision, up_provision
reserve_provisiondnDown-reserve provision variable (MW reserved down); also accepts dprovision, dn_provision, down
reserve_zoneupUp-reserve requirement variable (MW of up-reserve); also accepts urequirement, up_requirement
reserve_zonednDown-reserve requirement variable (MW of down-reserve); also accepts drequirement, dn_requirement, down

Variable Scaling

Some LP variables are internally scaled to improve solver numerical conditioning. User constraints are written in physical units; the constraint resolver automatically applies the appropriate scale factor so that the LP constraint is dimensionally correct.

VariableScale factor (physical = LP × scale)Default
reservoir.volume / reservoir.energyenergy_scale1000
reservoir.extractionflow_scale (= energy_scale)1000
reservoir.spill / reservoir.drainflow_scale (= energy_scale)1000
battery.energyenergy_scale1.0
battery.spill / battery.drainflow_scale1.0
bus.theta / bus.angle1 / scale_theta1/1000
All other variables1.0 (no scaling)

For example, reservoir("R1").volume >= 5000 (in dam³) is automatically translated to the LP constraint energy_scale × volume_LP ≥ 5000, accounting for the fact that the LP variable stores volume_physical / energy_scale.


4. Element Identification

Elements can be referenced by name (single-quoted string) or by numeric UID (bare integer):

# By name (single-quoted string)
generator('TORO').generation
demand('D1').load
line('L1_2').flow

# By explicit UID prefix (single-quoted string)
generator('uid:23').generation

# By bare numeric UID (integer — automatically treated as uid:N)
generator(3).generation        -- equivalent to generator('uid:3')
demand(7).load                 -- equivalent to demand('uid:7')
battery(1).energy              -- equivalent to battery('uid:1')

Mixing name and UID references in the same expression is allowed:

generator('G1').generation + generator(5).generation <= 300

5. Aggregation with <tt>sum()</tt>

The sum() function aggregates a variable across multiple elements of the same type, inspired by AMPL's sum{...} syntax. This avoids listing each element individually.

Syntax

sum( element_type ( id_list ) . attribute )

Where id_list is one of:

  • Explicit list: ‘'G1’, 'G2', 'G3'or1, 2, 3or mixed
  • **All elements**:all`

Examples

# Sum generation over specific generators (by name)
sum(generator('G1', 'G2', 'G3').generation) <= 500

# Sum generation over specific generators (by UID)
sum(generator(1, 2, 3).generation) <= 500

# Mixed name and UID references
sum(generator('G1', 2, 'uid:3').generation) <= 500

# Sum over ALL generators in the system
sum(generator(all).generation) <= 1000

# Sum with a coefficient
0.5 * sum(demand('D1', 'D2').load) <= 200

# Combined: sum + individual elements
sum(generator('G1', 'G2').generation) + demand('D1').load <= 1000

# Balance constraint: total generation minus total demand
sum(generator(all).generation) - sum(demand(all).load) = 0

AMPL comparison

gtoptAMPL equivalent
‘sum(generator('G1’,'G2').generation)\ilinebr </td> <td class="markdownTableBodyNone">sum{g in {"G1","G2"}} generation[g]\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone">sum(generator(all).generation)\ilinebr </td> <td class="markdownTableBodyNone">sum{g in GENERATORS} generation[g]\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone">0.5 * sum(demand(all).load)\ilinebr </td> <td class="markdownTableBodyNone">0.5 * sum{d in DEMANDS} load[d]`

6. Domain Specifications

By default, a constraint applies to every scenario, stage, and block. Use a for(...) clause to restrict the domain:

Dimension names

DimensionMeaning
scenarioScenario index
stageStage (investment period) index
blockBlock (operating hour) index

Index set forms

FormMeaningExample
allEvery indexstage in all
NSingle valuestage = 3
N..MRange (inclusive)block in 1..24
{N, M, ...}Explicit setstage in {1, 3, 5}
{N, M..P, Q}Mixed values and rangesblock in {1, 5..10, 20}

Syntax variants

Both in and = are accepted:

for(stage in {1,2,3})     -- using 'in'
for(stage = 1)            -- using '=' (single value)

Unspecified dimensions default to all:

for(block in 1..24)       -- all scenarios, all stages, blocks 1-24

7. Comments

Expressions support line comments using # or //. Everything after the comment marker to the end of the line is ignored:

generator('G1').generation <= 100   # limit gen output

generator('G1').generation          // first gen
+ generator('G2').generation        // second gen
<= 300

Multi-line expressions with comments are useful for documenting complex constraints:

# Total system generation capacity constraint
sum(generator(all).generation)    # MW total
<= 1000                          # system-wide limit
, for(block in 1..24)             # applies to all 24 blocks

8. Examples

Example 1 — Simple generation cap

Limit generator G1 to 100 MW:

{
  "uid": 1,
  "name": "g1_cap",
  "expression": "generator('G1').generation <= 100"
}

Example 2 — Joint generation limit

The sum of two generators must not exceed 300 MW:

{
  "uid": 2,
  "name": "gen_pair_limit",
  "expression": "generator('TORO').generation + generator('uid:23').generation <= 300, for(stage in {4,5,6}, block in 1..30)"
}

Example 3 — Minimum generation requirement

Generator G1 must produce at least 50 MW:

{
  "uid": 3,
  "name": "min_gen",
  "expression": "generator('G1').generation >= 50"
}

Example 4 — Line flow limit

Restrict flow on line L1_2 to 200 MW:

{
  "uid": 4,
  "name": "flow_limit",
  "expression": "line('L1_2').flow <= 200"
}

Example 5 — Generation-load balance

Generator G1 output must equal demand D1 load:

{
  "uid": 5,
  "name": "gen_demand_balance",
  "expression": "generator('G1').generation = demand('D1').load"
}

Example 6 — Range constraint

Generator output must be between 50 and 250 MW:

{
  "uid": 6,
  "name": "gen_range",
  "expression": "50 <= generator('G1').generation <= 250"
}

Example 7 — Weighted sum with coefficients

Partial contributions from two generators:

{
  "uid": 7,
  "name": "weighted_cap",
  "expression": "0.8 * generator('G1').generation + 0.5 * generator('G2').generation <= 200"
}

Example 8 — Cross-element CHP coupling

Model combined heat-and-power relationship (generation proportional to load):

{
  "uid": 8,
  "name": "chp_coupling",
  "expression": "generator('CHP').generation - 1.5 * demand('HeatLoad').load = 0"
}

Example 9 — Battery energy limit during peak hours

Limit battery state of energy during peak blocks:

{
  "uid": 9,
  "name": "bess_peak_limit",
  "expression": "battery('BESS1').energy <= 400, for(block in {18, 19, 20, 21})"
}

Example 10 — Scenario-specific constraint

Different limit in scenarios 1 and 2 only:

{
  "uid": 10,
  "name": "scenario_limit",
  "expression": "generator('G1').generation <= 150, for(scenario in {1, 2})"
}

Example 11 — Inactive constraint (disabled)

A constraint that is defined but not active:

{
  "uid": 11,
  "name": "maintenance_limit",
  "active": false,
  "expression": "generator('G1').generation <= 10"
}

Example 12 — Zero unserved energy requirement

Force no load curtailment on demand D1:

{
  "uid": 12,
  "name": "no_curtailment",
  "expression": "demand('D1').fail = 0"
}

Example 13 — Generator referenced by numeric UID

Use the bare integer syntax instead of ‘'uid:3’`:

{
  "uid": 13,
  "name": "gen_uid_limit",
  "expression": "generator(3).generation <= 200"
}

Example 14 — Sum over all generators (budget constraint)

Limit total system generation using sum():

{
  "uid": 14,
  "name": "total_gen_cap",
  "expression": "sum(generator(all).generation) <= 1000"
}

Example 15 — Sum over specific generators

Constrain a subset of generators:

{
  "uid": 15,
  "name": "thermal_limit",
  "expression": "sum(generator('G1', 'G2', 'G3').generation) <= 500, for(block in 1..12)"
}

Example 16 — Sum with coefficient (weighted budget)

Weighted sum of demand served:

{
  "uid": 16,
  "name": "weighted_demand",
  "expression": "0.5 * sum(demand('D1', 'D2').load) <= 200"
}

Example 17 — Balance: total generation equals total demand

System-wide power balance using two sum() terms:

{
  "uid": 17,
  "name": "system_balance",
  "expression": "sum(generator(all).generation) - sum(demand(all).load) = 0"
}

Example 18 — Reservoir volume constraint

Limit reservoir volume during dry season:

{
  "uid": 18,
  "name": "reservoir_min_vol",
  "expression": "reservoir('RES1').volume >= 1000, for(stage in {3, 4})"
}

Example 19 — Converter charge/discharge limit

Limit total converter throughput:

{
  "uid": 19,
  "name": "converter_limit",
  "expression": "converter('CV1').charge + converter('CV1').discharge <= 100"
}

Example 20 — Expression with comments

Use # or // for inline documentation (useful in external files):

{
  "uid": 20,
  "name": "documented_limit",
  "expression": "generator('G1').generation + generator('G2').generation <= 300 # peak capacity"
}

Example 21 — Reserve provision limit

Limit up-reserve provision of a specific provider:

{
  "uid": 21,
  "name": "up_reserve_limit",
  "expression": "reserve_provision('RP1').up <= 50"
}

Example 22 — Reserve zone total up-reserve

Constrain total up-reserve in a zone across all provisions:

{
  "uid": 22,
  "name": "zone_up_reserve_min",
  "expression": "reserve_zone('RZ1').up >= 100"
}

9. External Constraint Files

When there are many constraints, store them in a separate file.

JSON format

{
  "system": {
    "bus_array": [...],
    "user_constraint_file": "constraints.json"
  }
}

External JSON file (constraints.json):

[
  {
    "uid": 1,
    "name": "gen_limit",
    "expression": "generator('G1').generation <= 100"
  },
  {
    "uid": 2,
    "name": "flow_limit",
    "expression": "line('L1').flow <= 200"
  }
]

PAMPL format

PAMPL (pseudo-AMPL) files provide a more readable syntax with named constraints, parameters, and comments:

# System constraints
param pct_elec = 35;
param seasonal[month] = [0,0,0,100,100,100,100,100,100,100,0,0];

constraint gen_limit "Combined generation limit":
  generator('G1').generation + generator('G2').generation <= 300;

constraint seasonal_limit:
  generator('G1').generation <= pct_elec * seasonal[month];

PAMPL files are loaded automatically when referenced:

{
  "system": {
    "user_constraint_file": "constraints.pampl"
  }
}

Multiple external files

Use user_constraint_files (plural, array) to load multiple files independently. Each file is parsed with auto-incremented UIDs to avoid collisions:

{
  "system": {
    "user_constraint_files": [
      "laja_agreement.pampl",
      "maule_agreement.pampl"
    ]
  }
}

This keeps each constraint set self-contained and avoids combining files. Both user_constraint_file (singular) and user_constraint_files (plural) can coexist — all sources are accumulated.

Combining inline and external

Both user_constraint_array and external files can be used simultaneously. Constraints from all sources are accumulated:

{
  "system": {
    "user_constraint_array": [
      {"uid": 1, "name": "inline_limit", "expression": "..."}
    ],
    "user_constraint_files": ["more_constraints.pampl"]
  }
}

Multi-file merge

When loading from multiple JSON files (the standard gtopt pattern), constraints from all files are accumulated via System::merge():

gtopt base.json overrides.json

10. Formal Grammar (BNF)

constraint     := expr comp_op expr [',' for_clause]
               |  number comp_op expr comp_op number [',' for_clause]

expr           := term (('+' | '-') term)*

term           := [number '*'] element_ref
               |  [number '*'] sum_expr
               |  ['-'] number

element_ref    := element_type '(' element_id ')' '.' IDENT

sum_expr       := 'sum' '(' element_type '(' id_list ')' '.' IDENT ')'

id_list        := 'all'
               |  element_id (',' element_id)*

element_id     := STRING          -- name: 'G1' or 'uid:3'
               |  number          -- bare UID: 3 → uid:3

element_type   := 'generator' | 'demand' | 'line' | 'battery'
               |  'converter' | 'reservoir' | 'bus'
               |  'waterway' | 'turbine'
               |  'junction' | 'flow' | 'filtration'
               |  'reserve_provision' | 'reserve_zone'

comp_op        := '<=' | '>=' | '='

for_clause     := 'for' '(' index_spec (',' index_spec)* ')'

index_spec     := index_dim ('in' | '=') index_set

index_dim      := 'scenario' | 'stage' | 'block'

index_set      := 'all'
               |  '{' index_values '}'
               |  number '..' number
               |  number

index_values   := index_value (',' index_value)*

index_value    := number
               |  number '..' number

comment        := ('#' | '//') <anything to end of line>

STRING         := '"' <characters> '"' | "'" <characters> "'"
IDENT          := [a-zA-Z_][a-zA-Z0-9_]*
number         := [0-9]+ ('.' [0-9]+)?

11. Comparison with AMPL

AMPL equivalents

# ── gtopt: simple capacity constraint ──
# generator('G1').generation <= 100
# AMPL:
subject to g1_cap:
  generation["G1"] <= 100;

# ── gtopt: sum over element group ──
# sum(generator('G1','G2','G3').generation) <= 500, for(block in 1..12)
# AMPL:
subject to thermal_limit {b in 1..12}:
  sum{g in {"G1","G2","G3"}} generation[g,b] <= 500;

# ── gtopt: budget constraint (sum over all) ──
# sum(generator(all).generation) <= 1000
# AMPL:
subject to budget_constraint:
  sum{g in GENERATORS} generation[g] <= 1000;

# ── gtopt: cross-element balance ──
# sum(generator(all).generation) - sum(demand(all).load) = 0
# AMPL:
subject to balance:
  sum{g in GENERATORS} generation[g] - sum{d in DEMANDS} load[d] = 0;

# ── gtopt: weighted sum with domain ──
# 0.8 * generator('G1').generation + 0.5 * generator('G2').generation <= 200,
#     for(stage in {4,5,6}, block in 1..30)
# AMPL:
subject to weighted_cap {s in STAGES, b in BLOCKS: s in {4,5,6} and b >= 1 and b <= 30}:
  0.8 * generation["G1",s,b] + 0.5 * generation["G2",s,b] <= 200;

Key differences from AMPL

AspectAMPLgtopt
Element accessgeneration["G1",s,b]‘generator('G1’).generation\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Element by UID \ilinebr </td> <td class="markdownTableBodyNone"> Not applicable \ilinebr </td> <td class="markdownTableBodyNone">generator(3).generation\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Sum syntax \ilinebr </td> <td class="markdownTableBodyNone">sum{g in SET} expr\ilinebr </td> <td class="markdownTableBodyNone">sum(element_type(list).attr)\ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Index sets \ilinebr </td> <td class="markdownTableBodyNone">{s in STAGES: s >= 4}\ilinebr </td> <td class="markdownTableBodyNone">for(stage in {4,5,6})\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Constraint name \ilinebr </td> <td class="markdownTableBodyNone">subject to name:\ilinebr </td> <td class="markdownTableBodyNone">"name": "..."field \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Comments \ilinebr </td> <td class="markdownTableBodyNone">#only \ilinebr </td> <td class="markdownTableBodyNone">#and//\ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> File format \ilinebr </td> <td class="markdownTableBodyNone">.modtext file \ilinebr </td> <td class="markdownTableBodyNone"> JSON field or external file \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Scope \ilinebr </td> <td class="markdownTableBodyNone"> Full modeling language \ilinebr </td> <td class="markdownTableBodyNone"> LP constraints only \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Set definitions \ilinebr </td> <td class="markdownTableBodyNone"> Explicitset GENERATORS;`Implicit from system model

Design philosophy

The gtopt constraint language is intentionally narrower than AMPL:

  • No set definitions needed: Element sets are implicit from the system model. sum(generator(all).generation) automatically sums over all generators in the system, without requiring a set GENERATORS; declaration.
  • Element-centric: Variables are accessed via element references (‘generator('G1’).generation) rather than indexed arrays (generation["G1",s,b]`). This is more natural for power system engineers.
  • JSON-native: Constraints live in JSON files alongside the rest of the case definition, enabling programmatic generation from scripts and GUIs.

12. Best Practices

  1. Name constraints meaningfully: use descriptive names like gen_pair_limit or night_battery_reserve, not c1 or test.
  2. Start without domain restrictions: let the constraint apply to all time steps first, then narrow with for(...) as needed.
  3. Use UIDs for stability: ‘generator('uid:5’)orgenerator(5)is stable across name changes;generator('TORO')` breaks if the generator is renamed.
  4. Prefer sum() over manual expansion: use sum(generator(all).generation) instead of listing every generator individually — it's shorter, self-documenting, and auto-adapts when generators are added or removed.
  5. Use comments to document intent: add # ... or // ... comments to explain why a constraint exists, not just what it does.
  6. Set active: false for debugging: disable a constraint without removing it from the file.
  7. Use external files for large constraint sets: when you have more than ~10 constraints, move them to a separate file referenced by user_constraint_file.
  8. Validate with use_single_bus: true first: check that your constraints are feasible in a simple model before adding network constraints.
  9. Check LP feasibility: if adding user constraints makes the problem infeasible (status != 0), check output/Demand/fail_sol.csv for unserved demand.

13. See Also

  • Irrigation Agreements — Laja and Maule agreement modeling, FlowRight/VolumeRight entities, PLP comparison
  • Input Data Reference — Full JSON input format specification (§3.18 for UserConstraint fields)
  • Mathematical Formulation — LP/MIP formulation details
  • Planning Guide — Step-by-step planning guide
  • Usage Guide — Command-line options and output interpretation