Skip to content

Generic Constraints

Generic constraints are written in an authoring language: three input files, one output, and a grammar (named @expressions, inline relational right-hand sides, interval bounds). This page is the authority for that language. For the per-file field tables, see Constraint Files; for the LP formulation these constraints desugar into, see LP Formulation §10.

FileRole
constraints/generic_constraints.jsonDeclares the constraints, each a linear expression over the variable catalog with an optional inline bound and a slack setting, and the named expressions they share; field table: Constraint Files.
constraints/generic_constraint_bounds.parquetThe activation grid: one row per constraint, stage and optional block the constraint is active for, carrying the nullable lower and upper bound endpoints (the parquet base); field table: Constraint Files.
constraints/generic_parameters.jsonDeclares the named scalar parameters that constraint expressions, inline bound endpoints and other named expressions reference by @name; field table: Constraint Files.
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.

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 bit-for-bit comparison of the two values, never a tolerance — a very narrow but distinct band stays band, not equality.

For example, one constraint with both endpoints on its bounds row derives the band shape:

constraints/generic_constraints.json
{
"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

The two endpoints differ, so the row derives band.

The bounds file’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. 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.jsonbase + 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 constraint with no inline affine remainder and no rows is not an error; it applies to nothing.
  • 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 (as in the security-curve example). 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.

An expression string follows the grammar below; whitespace between tokens is ignored and every name is case-sensitive.

relation ::= side ( ( "<=" | ">=" | "==" ) side )?
side ::= ( "+" | "-" )? term ( ( "+" | "-" ) term )*
term ::= number "*" "@" name "*" variable | "@" name "*" variable
| number "*" "@" name | "@" name
| number "*" variable | number "*" group | group | variable
| number
group ::= "(" ( "+" | "-" )? term ( ( "+" | "-" ) term )* ")"
variable ::= var_name "(" integer ( "," integer )? ( "," "bus" "=" integer )? ")"
| "line_exchange" "(" bus_pair ")"
bus_pair ::= "source_bus" "=" integer "," "target_bus" "=" integer
| "target_bus" "=" integer "," "source_bus" "=" integer
number ::= one or more digits with at most one ".",
then an optional exponent ("e" or "E", an optional sign, digits)
integer ::= a non-negative whole number
name ::= a letter or "_", then letters, digits or "_"

The productions do not show these rules:

  • A bare number term is accepted only on a side of a relation, never inside a group and never in an expression with no relational operator.
  • An expression holds at most one relational operator outside parentheses.
  • var_name is one of the names in the variable catalog. Only hydro_turbined and hydro_generation take bus=; hydro_storage, hydro_withdrawal and anticipated_decision take no block argument; only line_exchange takes a bus_pair.
  • Write @ directly against its name (@name, never @ name). How an @name term reads (a parameter coefficient or a named-expression reference) is stated under The @name grammar.
  • Give at most one positional block argument: a second one, equal or not, is rejected when the case loads (Error Codes — SchemaViolation).
  • A named-expression definition in expressions takes neither a relational operator nor a bare number.

Each construct below is rejected when the case loads, reported as SchemaViolation.

A sign on a later term. Write hydro_generation(0) - thermal_generation(0).

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "hydro_generation(0) + -thermal_generation(0)",
"slack": { "enabled": false }
}
]
}

Division. Write 0.5 * hydro_generation(0).

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "hydro_generation(0) / 2",
"slack": { "enabled": false }
}
]
}

A coefficient after the variable. Write 2 * hydro_generation(0).

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "hydro_generation(0) * 2",
"slack": { "enabled": false }
}
]
}

A bare < or >. Write <= or >=.

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "hydro_generation(0) < 30",
"slack": { "enabled": false }
}
]
}

A constant inside a group. Write hydro_generation(0) <= 2 * thermal_generation(0) + 10.

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "hydro_generation(0) <= 2 * (thermal_generation(0) + 5)",
"slack": { "enabled": false }
}
]
}

Two relational operators. Give both endpoints in the bounds file, or write two constraints.

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "10 <= hydro_generation(0) <= 30",
"slack": { "enabled": false }
}
]
}

A name in another case. Write hydro_generation(0).

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "Hydro_generation(0)",
"slack": { "enabled": false }
}
]
}

A second positional block argument on one variable, equal or not.

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "c1",
"expression": "thermal_generation(0, 0, 1) <= 10",
"slack": { "enabled": false }
}
]
}

An expression can name any of the 26 variables below. 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])YesNoOne stage-level quantity on a parallel stage, one per block on a chronological stage; some references are rejected on their block argument — see block references
hydro_inflow(id[, block])YesNoThe plant’s inflow in the block (hydro_inflow); a bound with no block_id expands to one row per block; a hydro_inflow term never collapses to a stage-level row, and a term with no block argument reads the block of its row
hydro_storage_initial(id[, block])Yes (boundary)NoWith a block k, the storage at the start of block k; with no block, the stage’s initial storage
hydro_storage_final(id[, block])Yes (boundary)NoWith a block k, the storage at the end of block k; with no block, the stage’s final storage (the hydro_storage value)
hydro_useful_volume_initial(id[, block])Yes (boundary)NoSame column and block semantics as hydro_storage_initial; stands for storage − reservoir.min_storage_hm3 (see below)
hydro_useful_volume_final(id[, block])Yes (boundary)NoSame column and block semantics as hydro_storage_final; stands for storage − reservoir.min_storage_hm3 (see below)
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 of a thermal that declares anticipated_config; 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.” A (hydro, bus) cell is the part of a plant on one bus: a plant has one cell per distinct bus among its unit groups, and bus= must name one of those buses.

A hydro_withdrawal, non_controllable_generation or non_controllable_curtailment term is accepted when the case loads but adds no coefficient to the LP row. A contract_import or contract_export term adds a coefficient only when the named contract exists and has that direction, a hydro_evaporation term only on a stage where the plant has an evaporation model and is not PreFilling, and a hydro_generation term on a plant with an FPHA model only on a stage where the plant is neither PreFilling nor Filling; in these cases the term’s quantity is zero. The resolved echo still lists every such term.

A constraint whose every term adds no coefficient leaves a row with no LP variable from its terms: without a slack the LP is infeasible when the bound excludes zero, and the row has no effect otherwise; with a slack, the slack absorbs the excluded amount at the constraint’s slack penalty. When only some terms add no coefficient, the bound applies to the remaining terms alone.

Useful-volume bound fold. A term c * hydro_useful_volume_initial(h) or c * hydro_useful_volume_final(h) is built on plant h’s storage column, and c × min_storage_hm3 of plant h — the physical minimum from reservoir.min_storage_hm3, never a hydro_bounds per-stage override — is added to every resolved bound endpoint of the constraint, once per such term (c being the term’s fully resolved coefficient, so a @name coefficient contributes its resolved value). A negative c lowers the endpoints by the same rule. The constraint therefore reads as a quantity above dead storage while the LP column keeps the absolute storage value; no extra LP variable is created, and the folded endpoints are visible in the resolved echo.

With h a hydro id and K the number of blocks the stage declares, the generic-constraint check rejects a hydro_evaporation reference that the stage’s block_mode cannot expose:

  • On a parallel stage with two or more blocks, hydro_evaporation(h) and hydro_evaporation(h, 0) address the one stage-level evaporation, and a block from 1 to K - 1 is rejected.
  • On a chronological stage with more than one block, a block is required: hydro_evaporation(h) is rejected.
  • On a stage with one block, on either stage mode, hydro_evaporation(h) and hydro_evaporation(h, 0) both address the single block.
  • A block at or beyond K is rejected on every stage, for an evaporation or a storage reference.
  • The same check rejects a hydro_storage_initial, hydro_storage_final, hydro_useful_volume_initial or hydro_useful_volume_final reference that names an interior block boundary, one between two blocks, on a parallel stage.
hydro_evaporation(h) parallel: the stage-level evaporation; chronological with K > 1: rejected
hydro_evaporation(h, 1) parallel: rejected; chronological with K > 1: block 1

The check runs only for a (constraint, stage) pair that has a row in generic_constraint_bounds.parquet. Each rejection is a BusinessRuleViolation; the messages are quoted in Error Codes — Per-block generic-constraint references.

For any reference not covered above, the block argument is not checked when the case loads: keep it below K on every stage where the constraint is active. A block at or beyond K is accepted, and the term can then land on a column outside that entity’s own blocks. novomodelo validate and novomodelo run stop with an index-out-of-bounds panic when the block addresses past the last column of the stage LP, and for a block at or beyond K on a hydro_inflow term of a plant that receives water through an upstream travel_time_hours arc, directly or through an upstream PreFilling plant that has such an arc.

A hydro_inflow term reads the plant’s inflow in the block, a rate in m³/s, made of:

  • the plant’s local inflow;
  • the flows diverted into it;
  • the upstream releases credited to the block by travel time;
  • the share of the in-transit volume maturing at the stage that arrives in the block, as a rate;
  • the water of upstream PreFilling plants passing through.

It excludes pumping, the inflow non-negativity slack, the plant’s own outflows, evaporation and withdrawal. On a parallel stage only the duration-weighted stage total matches the water balance; the split across blocks is a convention. For a plant that is PreFilling at the stage, the plant’s inflow enters the water-balance row of its nearest downstream plant that is not PreFilling, so the term equals no row’s own inflow.

Definition: LP Formulation — Hydro Inflow.

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. Novomodelo 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 reads exactly the line that the direct-id form reads, with the orientation sign applied. When two lines connect the same pair of buses, the pair identifies no single line, and the case is rejected whenever it has a constraints/generic_constraints.json file, even if no expression uses the pair form (Authoring errors).

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)line_exchange(id)sign +1 or -1

Two authoring roles share one namespace: scalar parameters (declared in constraints/generic_parameters.json) and named expressions (declared in the constraints file’s own optional top-level expressions array and shared by every constraint). 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:

constraints/generic_constraints.json
{
"expressions": [
{ "name": "base", "expression": "hydro_generation(0)" },
{ "name": "inner", "expression": "3.0 * @base" },
{ "name": "outer", "expression": "@inner + hydro_generation(1)" }
],
"constraints": [
{ "id": 1, "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. A reference cycle among named expressions, including an expression that references itself, is rejected before any constraint is built, even when no constraint uses those expressions; the message names the cycle (a -> b -> a).

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. Declaring such an expression is not itself an error: every reference must resolve when the expression is declared, regardless of its size, and 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"cycle checksubstitute terms[SchemaViolation]"a -> b -> a" acycliccycle found

An expression carries at most one relational operator (Expression grammar); for a range, give both endpoints in the bounds file or write two constraints.

  • No operator: expression is the flat one-sided LHS; 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:

constraints/generic_constraints.json
{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(10) + hydro_generation(11)" }
],
"constraints": [
{ "id": 1, "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 file’s top-level JSON key is "scalar_parameters" (an array). The field table lists the field each kind fills.

kindValue semantics
constantOne value for every stage
per_stageExplicit value per stage; stage_ids must be a contiguous range from 0
seasonalOne value per season; stages inherit their season’s value; season_ids must be unique
computedDerived from hydro geometry/operational data at LP-build time — one of ten hydro-derived tags (e.g. equivalent_productivity); see Constraint Files
per_stage_blockExplicit value per (stage_id, block_id) pair; each pair unique

The vendored generic_parameters.schema.json lists the five kinds above. For editor validation with the vendored schema, see JSON Schemas.

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. slack is required on every constraint; with enabled: true, penalty is required and must be greater than zero (Authoring errors). How many LP columns the slack adds 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 at the output root (<out>/generic_constraints/) 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.

NameTypeNullableUnitsDescription
stage_idInt32No—Study stage id
block_idInt32Yes—Null on a collapsed stage-level row
constraint_idInt32No—Generic constraint id
constraint_nameUtf8No—Generic constraint name
term_indexInt32Yes—Position in the resolved LHS; null on the term-less placeholder row
variable_kindUtf8Yes—Variable name (e.g. "thermal_generation"); null on the placeholder
variableUtf8Yes—Rendered variable label (e.g. "thermal_generation(id=3, block=all)"); null on the placeholder
coefficientFloat64Yes—Fully resolved numeric coefficient (resolve(coefficient, stage, block) * scale); null on the placeholder
bound_lowerFloat64Yes—Lower interval endpoint after folding — including each useful-volume term’s coef × min_storage_hm3 shift; null when unbounded below
bound_upperFloat64Yes—Upper interval endpoint after folding — including each useful-volume term’s coef × min_storage_hm3 shift; null when unbounded above
derived_shapeUtf8No—The same floor/cap/band/equality label as above — never an authored sense
slack_enabledBooleanNo—Whether the constraint carries a slack term
slack_penaltyFloat64Yes—Slack penalty; null when slack is disabled

The echo is the place to observe a folded bound: the input constraints/generic_constraint_bounds.parquet shows the pre-fold value. For example, a floor hydro_useful_volume_final(0) >= 20 on a plant whose reservoir.min_storage_hm3 is 100 echoes bound_lower = 120.0 (20 + 1 × 100) with variable = "hydro_useful_volume_final(id=0, block=all)".

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, apart from the terms that add no coefficient (see the note under the variable catalog) and a term with an out-of-range block (block references) — the tool for auditing an authored expression against the LP that was actually built.


A security curve keeps plant h’s end-of-stage stored energy above a fraction p of its maximum, stated in energy terms with the useful-range mean productivity of Hydro Production Function Models §5.3: @rho_int * hydro_useful_volume_final(h) >= p * @emax.

constraints/generic_parameters.json declares the two computed parameters for hydro 0:

constraints/generic_parameters.json
{
"scalar_parameters": [
{
"id": 1,
"name": "rho_int",
"kind": "computed",
"computed_spec": { "tag": "integrated_accumulated_productivity", "hydro_id": 0 }
},
{
"id": 2,
"name": "emax",
"kind": "computed",
"computed_spec": { "tag": "max_stored_energy", "hydro_id": 0 }
}
]
}

constraints/generic_constraints.json states the curve for p = 0.3:

constraints/generic_constraints.json
{
"constraints": [
{
"id": 1,
"name": "security_curve_h0",
"expression": "@rho_int * hydro_useful_volume_final(0) >= 0.3 * @emax",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}

The inline RHS carries the whole bound, but the constraint still needs activation rows in constraints/generic_constraint_bounds.parquet — one row per active stage, with both bounds null; without them the constraint is never built (the activation grid):

constraint_idstage_idblock_idbound_lowerbound_upper
10nullnullnull
11nullnullnull
……………

Why it cancels. integrated_accumulated_productivity and max_stored_energy are evaluated by the same useful-range mean evaluator and in the same raw unit, so @emax is exactly @rho_int times the useful volume; hydro_useful_volume_final reads storage above the physical minimum, the same range @emax spans. Pairing max_stored_energy with the reference-point accumulated_productivity instead does not cancel and raises a SemanticAmbiguity warning.

What the echo shows. On a plant with reservoir.min_storage_hm3 = 100, reservoir.max_storage_hm3 = 1000, specific_productivity_mw_per_m3s_per_m = 0.0088 and a three-row volume-height-area geometry table (100, 500 and 1000 hm³), the stage-0 resolved echo row reads coefficient = 2.8136, bound_lower = 1041.02 and variable = "hydro_useful_volume_final(id=0, block=all)": the RHS 0.3 × 2532.2 (0.3 × @emax, where @emax = 2.8136 × (1000 − 100)) plus the useful-volume fold 2.8136 × 100 (@rho_int × min_storage_hm3).

Writing the same curve by hand on hydro_storage_final(h), with the dead-volume energy @rho_int × min_storage_hm3 pre-folded into the bound (on the same plant, bound_lower = 1041.02 on @rho_int * hydro_storage_final(0)) is equivalent.


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. The Kind column names each failure’s kind; on an error line, novomodelo validate prints it in brackets, as in error: [SchemaViolation] ….

SituationKind
An unknown variable name, including a catalog name written in another case (Hydro_generation(0))SchemaViolation
An unknown @parameter, or any construct under Not supportedSchemaViolation
bus= on a variable other than hydro_turbined and hydro_generation, or a block argument on hydro_storage, hydro_withdrawal or anticipated_decision, or a second positional block argument (hydro_turbined(5, 0, 1))SchemaViolation
A duplicate constraint id, or a duplicate parameter id or nameSchemaViolation
A name declared both as a scalar parameter and as a named expressionSchemaViolation
A reference cycle among named expressions, a self-reference includedSchemaViolation
A key the constraint entry does not declare (for example sense)ParseError
A constraint entry with no slack objectParseError
slack.enabled: true with no penalty, or with a penalty of zero or lessSchemaViolation
A term naming an entity id the case does not declare (hydro_generation(7) with no hydro 7)InvalidReference
bus= naming a bus on which the plant has no unit groupInvalidReference
anticipated_decision(N) where thermal N has no anticipated_configBusinessRuleViolation
line_exchange(source_bus=, target_bus=) naming two buses that no line connectsSchemaViolation
Two lines connecting the same pair of buses, in either direction, in a case that has constraints/generic_constraints.json, whether or not an expression uses the bus-pair formSchemaViolation
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
A bounds row whose block_id is not a block of its stageBusinessRuleViolation
An evaporation or storage reference naming a block that its stage does not have (block references)BusinessRuleViolation
The same bound column (bound_lower or bound_upper) set twice for one (constraint_id, stage_id, block_id) — a bound_lower row and a bound_upper row for the same triple are distinct keys and legalDuplicateId
A contract_import or contract_export term naming no declared contractUnusedEntity — warning, the case still validates
max_stored_energy(h) and accumulated_productivity(h) for the same hydro in one constraint (the matching coefficient is integrated_accumulated_productivity)SemanticAmbiguity — warning, the case still validates