Skip to content

Generic Constraints

v0.14.0 turns generic constraints from a small per-file field table into a full authoring language: three input files, one output, and a grammar layer (named @expressions, inline relational RHS, interval bounds). This page is the authority for that language. For the per-file column tables, see Case Directory Format; for the LP formulation these constraints desugar into, see LP Formulation §10.

Every constraint carried one sense (">=", "<=", or "==") and every bounds row carried one bound column. Expressing a two-sided band meant authoring the same expression twice, under two different constraint ids:

{
"constraints": [
{
"id": 1,
"name": "southeast_hydro_floor",
"expression": "hydro_generation(10) + hydro_generation(11)",
"sense": ">=",
"slack": { "enabled": true, "penalty": 5000.0 }
},
{
"id": 2,
"name": "southeast_hydro_cap",
"expression": "hydro_generation(10) + hydro_generation(11)",
"sense": "<=",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}

generic_constraint_bounds.parquet (v0.13.0 shape, one bound column):

constraint_idstage_idblock_idbound
10null35.0
20null100.0

Two constraint ids, two LP rows, two slack variables — for one logical band.

After (v0.14.0): one constraint, a derived band

Section titled “After (v0.14.0): one constraint, a derived band”

sense is gone. generic_constraint_bounds.parquet now carries nullable bound_lower/bound_upper, and the shape (floor/cap/band/equality) is derived from which of the two is present:

{
"constraints": [
{
"id": 1,
"name": "southeast_hydro_band",
"expression": "hydro_generation(10) + hydro_generation(11)",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}
constraint_idstage_idblock_idbound_lowerbound_upper
10null35.0100.0

One constraint id, one row, bound_lower=35.0 != bound_upper=100.0 derives to "band".

Further (v0.14.0): inline relational RHS + a named expression

Section titled “Further (v0.14.0): inline relational RHS + a named expression”

Instead of hard-coding 35.0/100.0 in the parquet, the cap can be authored inline against a named scalar parameter, and the repeated LHS declared once as a named expression:

{
"expressions": [
{
"name": "southeast_hydro",
"expression": "hydro_generation(10) + hydro_generation(11)"
}
],
"constraints": [
{
"id": 1,
"name": "southeast_hydro_cap",
"expression": "@southeast_hydro <= @demand_cap",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}

with @demand_cap declared in constraints/generic_parameters.json. The bounds parquet still needs a row to activate constraint 1 at stage 0 — but with the inline <= supplying bound_upper entirely, that row’s own endpoints can both be null:

constraint_idstage_idblock_idbound_lowerbound_upper
10nullnullnull

This is legal only because the constraint’s own inline remainder fills bound_upper — see the activation grid below.


FileRole
constraints/generic_constraints.jsonDeclares each constraint’s id, name, expression (LHS, plus an optional inline relational operator), description?, and slack config; an optional top-level expressions array declares named linear expressions shared by every constraint.
constraints/generic_constraint_bounds.parquetThe activation grid: one row per (constraint_id, stage_id[, block_id]) cell the constraint is active for, carrying the nullable bound_lower/bound_upper parquet base.
constraints/generic_parameters.jsonDeclares named scalar parameters (top-level key "scalar_parameters", unchanged across the rework) referenced by @name from constraint expressions, inline bound endpoints, and other named expressions.
generic_constraints/resolved_echo.parquet (output)The fully-resolved, desugared form of every generic constraint the solver actually built — one row per (constraint, stage, block, term). Written whenever the study has generic constraints; the directory is entirely absent otherwise.

sense no longer exists anywhere in the grammar. A bound’s shape is derived purely from which of bound_lower/bound_upper are present, after the parquet base and any inline affine remainder (below) have been folded together:

bound_lowerbound_upperDerived shape
presentabsentfloor
absentpresentcap
presentpresent, differentband
presentpresent, bit-identicalequality
absentabsentrejected (see the activation grid)

The equal-vs-different check is an exact f64::to_bits() comparison, never a tolerance — a very narrow but distinct band stays band, not equality.

The parquet’s numeric base and the constraint’s inline affine remainder compose additively on the same endpoint rather than conflicting: resolved = base + remainder when both are present on that endpoint, or either alone when only one is (fold_endpoint in the LP builder). The fold runs per (stage, block), since the remainder may itself resolve a stage/block-varying named parameter (a per_stage_block kind — see generic_parameters.json below).

A load-time inversion check (bound_upper < bound_lower) only fires when both endpoints are statically resolvable — a constant-only remainder, or none at all. A remainder carrying a live @param term makes the endpoint stage-varying, so its inversion is left to LP infeasibility at solve time rather than a combinatorial pre-solve check across every stage and block.

parquet basebound_upper (nullable)affine remainder: constantinline "<= 12.0"affine remainder: @name termresolved via generic_parameters.jsonfold_endpointbase + remainder (additive)resolved bound_upperLP row bound + resolved_echo

generic_constraint_bounds.parquet is the activation grid — there is no separate enable flag. A constraint is active at (stage, block) if and only if a row exists for it there.

  • A reference with no rows is rejected. A constraint whose only bound endpoints come from an inline affine remainder, but which has zero rows in the bounds parquet, fails referential validation: InvalidReference, “declares a bound reference but has no activation rows in generic_constraint_bounds.parquet: the reference would apply to nothing.” The parquet still decides where the constraint applies, even when it supplies none of the bound value.
  • A both-null row is legal only when a reference fills a side. A row with bound_lower = null and bound_upper = null is accepted when the constraint’s own bound_lower_affine/bound_upper_affine supplies at least one endpoint — the row’s only job is then to activate that cell; the value comes entirely from the remainder (this is exactly the third worked example above). A both-null row on a constraint with no affine remainder on either side is InvalidValue: “has neither bound_lower nor bound_upper: at least one endpoint is required.”
  • A degenerate equal band is accepted, an inverted one is not. bound_upper == bound_lower derives to equality and is fine; bound_upper < bound_lower (checked only when both are statically resolvable, per above) is InvalidValue: “an inverted interval is not allowed.”
  • block_id = null applies to every block of the stage. When the constraint’s expression — and any block-varying coefficient parameter on it — is block-independent, this collapses to a single stage-level LP row priced by the stage’s total hours, rather than one row per block. A per_stage_block-kind parameter anywhere in the constraint’s affine remainder suppresses that collapse even when the expression itself is block-independent, since one collapsed row would otherwise resolve one arbitrary block’s bound value and silently lose the per-block variation. Full row-materialization derivation: LP Formulation §10.

All 24 LP variable types are addressable from an expression. The reference grammar is var_name(entity_id[, block_id][, bus=bus_id]) — an optional positional block argument, then an optional named bus= selector, in that order (f(id), f(id, block), f(id, bus=b), f(id, block, bus=b) all parse).

VariableBlock argbus=Notes
hydro_storage(id)NoNoStage-level stock
hydro_withdrawal(id)NoNoStage-level
hydro_evaporation(id[, block])YesNoNone = block 0 (parallel-mode stage evaporation); rejected with None in chronological mode with more than one block — a block must be named
hydro_inflow(id[, block])YesNoNone always expands to one row per block (upstream releases are per-block columns) — never collapses
hydro_storage_initial(id[, block])Yes (boundary)NoSome(k) = incoming column of block k; None = stage-initial anchor S⁰
hydro_storage_final(id[, block])Yes (boundary)NoSome(k) = outgoing column of block k; None = stage-final Sᴷ (equal to hydro_storage)
hydro_turbined(id[, block][, bus=])YesYesbus= selects one (hydro, bus) cell; omitted sums over the plant’s cells
hydro_spillage(id[, block])YesNo
hydro_diversion(id[, block])YesNo
hydro_outflow(id[, block])YesNoDerived alias for turbined + spillage, not an independent LP column
hydro_generation(id[, block][, bus=])YesYesSame bus= semantics as hydro_turbined
thermal_generation(id[, block])YesNo
line_direct(id[, block])YesNoForward flow only
line_reverse(id[, block])YesNoReverse flow only
line_exchange(id[, block])YesNoNet flow (direct − reverse); also addressable by bus pair, below
bus_deficit(id[, block])YesNo
bus_excess(id[, block])YesNo
pumping_flow(id[, block])YesNo
pumping_power(id[, block])YesNo
contract_import(id[, block])YesNo
contract_export(id[, block])YesNo
non_controllable_generation(id[, block])YesNo
non_controllable_curtailment(id[, block])YesNo
anticipated_decision(id)No (rejected if given)NoStage-level scalar commitment column; no block index at all — use anticipated_decision(N)

Only hydro_turbined and hydro_generation accept bus=; any other variable rejects it as “does not accept a bus selector.”

Besides the direct-id form (line_exchange(id[, block])), line_exchange alone also accepts an endpoint-bus-pair form in place of the positional line id:

line_exchange(source_bus=3, target_bus=7)

source_bus= and target_bus= may appear in either order, but both are required, and this form takes no block argument — it always resolves to block_id: None. Cobre resolves the (source_bus, target_bus) pair against a line-topology index built from every declared line’s (source_bus_id, target_bus_id) pair and its reverse:

  • If the pair matches a line’s declared direction, the term’s orientation is forward (scale +1.0).
  • If the pair is reversed relative to the line’s declared direction, the orientation folds -1.0 into the term’s scale — so an author never needs to know which way a given line happened to be declared.

The pair form desugars at parse time to exactly the same LineExchange { line_id } term the direct-id form produces; there is no separate storage or downstream special-casing. If two distinct lines share the same unordered bus pair, the pair no longer identifies a single line and the reference is rejected at line-topology build time (“buses … are connected by more than one line … reference the intended line by its id, or sum the lines with a named expression”).

system/lines.jsonsource_bus_id, target_bus_idline-topology index(source,target) and its reverseline_exchange(source_bus=3, target_bus=7)resolve pair -> (line_id, orientation)LineExchange { line_id }scale *= +-1.0

Two authoring roles share one namespace: scalar parameters (declared in constraints/generic_parameters.json) and named expressions (declared in the constraints file’s own top-level expressions array). Declaring the same name in both is rejected: “name … is declared as both a scalar parameter and a named expression; the “@name” namespace is shared.”

Disambiguation. @name immediately followed by * variable is a scalar-parameter coefficient, resolved against generic_parameters.json. A bare @name, or coefficient * @name with no trailing variable, is a named-expression reference, resolved against expressions. @param * @name — stacking both roles in one term — is rejected outright: it has no flat linear-core representation.

Composition. A named expression may reference another named expression; composition resolves transitively, with each reference’s scale distributed into every substituted term:

{
"expressions": [
{ "name": "base", "expression": "hydro_generation(0)" },
{ "name": "inner", "expression": "3.0 * @base" },
{ "name": "outer", "expression": "@inner + hydro_generation(1)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "2.0 * @outer", "slack": { "enabled": false } }
]
}

2.0 * @outer inlines to 6.0 * hydro_generation(0) + 2.0 * hydro_generation(1).

Cycle detection. Before any substitution runs, an iterative three-colour DFS walks the reference graph. A self-reference (@e = @e) is a cycle of length one; a longer cycle (@a = @b, @b = @a) is rejected with the full path in the message ("a -> b -> a"). The walk is iterative, never recursive over the chain, so a deep dependency chain cannot overflow the stack.

Term budget. Substitution caps at 100,000 materialized terms per expansion — catching an exponential doubling chain (@e_k = @e_{k-1} + @e_{k-1}) fast, well before a 2⁶⁰-term vector would ever materialize. Declaring such an expression is not itself an error: an existence-only, non-expanding walk checks that every reference resolves at declaration time regardless of size; the term-budget cap only fires when something actually inlines it.

Grouping. A parenthesized group scaled by a leading literal coefficient (2.0 * (@fnese - hydro_generation(2))) distributes that coefficient into every inner term at parse time — the same flat term list the hand-expanded form would produce, to arbitrary nesting depth. Only a literal coefficient may scale a group; @param * (...) is rejected for the same reason as @param * @name.

@a"@b"@b"@a"detect_cycles()three-colour DFSinline()substitute termsSchemaError"a -> b -> a" acycliccycle found

Grammar: relation ::= side (('<=' | '>=' | '==') side)?. An expression carries at most one top-level relational operator; a double range (LI <= expr <= LS) is explicitly unsupported — split into two constraints, or fold both endpoints through the bounds-parquet base directly.

  • No operator: expression is the flat one-sided LHS (the v0.13.0-minus-sense shape); the interval comes entirely from the parquet base / affine-remainder mechanism described above.
  • One operator: every RHS variable term moves onto the merged LHS, sign-flipped; a same-variable, same-coefficient-kind pair (across either side) merges by summing its effective contribution, and a merged literal column that cancels to exactly 0.0 is dropped. Every non-variable term — a literal constant, or an @name that resolves as a scalar parameter — folds into the affine remainder the operator assigns: <= to the upper endpoint, >= to the lower, == to both (an identical remainder on each side).

A bound-position @name resolves as a scalar parameter only when it is not a declared named expression. When it is one, it still inlines into the merged LHS as variable terms rather than becoming part of the remainder:

{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(10) + hydro_generation(11)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "hydro_generation(0) >= @fnese", "slack": { "enabled": false } }
]
}

folds all three hydro_generation terms onto the merged LHS and assigns an explicit zero-constant lower bound — an all-variable RHS still supplies an affine bound, never “no bound.”


generic_parameters.json: the five parameter kinds

Section titled “generic_parameters.json: the five parameter kinds”

The top-level JSON key remains "scalar_parameters" (an array), unchanged across the rework, even though the file itself now lives at constraints/generic_parameters.json.

kindPayload fieldValue semantics
constant"value": <f64>One value for every stage
per_stage"values": [[stage_id, value], ...]Explicit value per stage; stage_ids must be a contiguous range from 0
seasonal"values": [[season_id, value], ...]One value per season; stages inherit their season’s value; season_ids must be unique
computed"computed_spec": { "tag": "<variant>", "hydro_id": <int> }Derived from hydro geometry/operational data at LP-build time (e.g. equivalent_productivity)
per_stage_block"block_values": [[stage_id, block_id, value], ...]Explicit value per (stage_id, block_id) pair; each pair unique

per_stage_block is the mechanism that makes a generic-constraint bound (or coefficient) genuinely block-varying — and, per the activation grid above, referencing one in an affine remainder suppresses the stage-level row collapse.


Slack config (slack.enabled/slack.penalty) is declared once per constraint and applies uniformly to every row the activation grid produces for it. How many LP columns that costs is a property of the row’s own resolved endpoint pair, never a constraint-level label:

Row shapeSlack columns
DisabledZero
One-sided (floor/cap)One (s⁺)
Two-sided (band/equality)Two (s⁺ then s⁻)

A two-sided bound needs slack in both directions to relax either endpoint independently — one column cannot represent “went below the floor” and “went above the cap” at once.

Cost charges both. Both columns are priced at penalty * block_hours in the objective (a stage-level row is priced by the stage’s total block hours; a per-block row, by its own block’s hours) — violating either direction costs the same per-unit penalty, and the LP can never let a +5 on one side and a -5 on the other net to a free violation.

The reported value nets them. In the simulation violations output, a one-sided row reports the raw (non-negative) slack primal. A two-sided row instead reports the signed net s⁺ − s⁻ (which may be negative) — deliberately different from the cost, which always sums s⁺ + s⁻. In practice both slacks are rarely simultaneously nonzero at an LP vertex, so the net is the more legible number for a results reader while the sum remains the correct charge.


generic_constraints/resolved_echo.parquet is written to the training output tree whenever the study declares any generic constraints; for a study with none, the generic_constraints/ directory does not exist at all.

One row is emitted per resolved LHS term per active (constraint, stage, block) cell, in canonical (constraint, stage, block, term) order — declaration-order invariant, so the same case always echoes the same rows in the same order. A term-less (constant-only) constraint contributes a single placeholder row whose per-term columns are all null.

ColumnTypeDescription
stage_idINT32Study stage id
block_idINT32, nullableNull on a collapsed stage-level row
constraint_idINT32Generic constraint id
constraint_nameUTF8Generic constraint name
term_indexINT32, nullablePosition in the resolved LHS; null on the term-less placeholder row
variable_kindUTF8, nullableVariableRef discriminant (e.g. "thermal_generation"); null on the placeholder
variableUTF8, nullableRendered variable label (e.g. "thermal[3]"); null on the placeholder
coefficientDOUBLE, nullableFully resolved numeric coefficient (resolve(coefficient, stage, block) * scale); null on the placeholder
bound_lowerDOUBLE, nullableLower interval endpoint after folding; null when unbounded below
bound_upperDOUBLE, nullableUpper interval endpoint after folding; null when unbounded above
derived_shapeUTF8The same floor/cap/band/equality label as above — never an authored sense
slack_enabledBOOLEANWhether the constraint carries a slack term
slack_penaltyDOUBLE, nullableSlack penalty; null when slack is disabled

This is the fully desugared form: every named-expression reference is already inlined into flat terms, every inline relational RHS has already normalized into the bound_lower/bound_upper pair, and every @name coefficient or bound has already resolved to its numeric value at that (stage, block). It is exactly what the solver saw — the tool for auditing an authored expression against the LP that was actually built.


The failure modes below are the ones an author is most likely to hit while writing this grammar; see Error Codes for the exhaustive catalog and resolutions — it is not repeated here.

SituationError
Unknown variable name, unknown @parameter, or a grammar violationSchemaError (parse-time)
Duplicate constraint id, duplicate parameter id/name, or a name colliding across the scalar-parameter/named-expression namespaceSchemaError
Reference cycle among named expressionsSchemaError (see CycleDetected)
Constraint declares a bound reference but has no activation rowsInvalidReference
A bounds row with neither endpoint, and no affine remainder to fill oneInvalidValue
Inverted interval (bound_upper < bound_lower, both statically resolvable)InvalidValue
block_id out of range for the stage’s block countInvalidValue
Duplicate (constraint_id, stage_id, block_id) key in the bounds parquetDuplicateId
Two distinct lines sharing one unordered bus pair, addressed via line_exchange(source_bus=, target_bus=)SchemaError