From f9d3dd6cebf9b79cdcefc9e6f24c0530a2bed76c Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 16:01:25 +0300 Subject: [PATCH 01/87] feat(stochastic): Slice A scenario-lineage frames (dd_same_scenario, pd_non_anticipativity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two always-on derived frames in _derived_branch.py, consumed by nothing (recourse plan §6b Slice A; design specs/sliceA_lineage_frames_design.md r3): - dd_same_scenario (d, d_other): symmetric/reflexive pairs sharing a scenario leaf-path; domain = period_in_use only; all-pairs degenerate content for deterministic solves; leaf rule with the F6 authority convention (branch-local member displaces the anchor). - pd_non_anticipativity (d, b): period-level invest-NA pairs (strictly-pre-reveal anchor ties), parameterized on an optional branch_start frame (Slice E hook); provably empty for every currently-constructible solve under the min-anchor fallback. Wiring: unconditional assignment in apply_branch_cluster (step 2b), guarded-ctx input acquisition per the dtttdt_from_source null-guard precedent, ValueError failure semantics on inconsistent per-solve artefacts, Utf8-construct -> non-strict cast_dim -> live null assertion -> deterministic Utf8-key sort. FlexData registration (two typed fields, None only pre-cascade), d_other axis synonym. No consumers, no schema change, no migration, no autoscale entries (nothing enters the LP), no dump-side change (_copy_meta already glob-copies the rebuild inputs). Tests: T1-T12 per design §4/§5 (17 tests) — five exact shape tables, both mid-horizon shapes incl. the branch_start-driven [(p1, p1_low)] pair, symmetry property, metadata exclusion, failure semantics, _drop_master_rows pass-through, integration + enum hygiene on the stoch_two_period fixture, and the first stochastic dump/reload round-trip (pins the _copy_meta glob). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_axis_enums.py | 3 + flextool/engine_polars/_derived_branch.py | 420 ++++++++++++++- flextool/engine_polars/input.py | 9 + .../test_derived_branch_lineage.py | 498 ++++++++++++++++++ 4 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 tests/engine_polars/test_derived_branch_lineage.py diff --git a/flextool/engine_polars/_axis_enums.py b/flextool/engine_polars/_axis_enums.py index 772b2ea33..603995d98 100644 --- a/flextool/engine_polars/_axis_enums.py +++ b/flextool/engine_polars/_axis_enums.py @@ -105,6 +105,9 @@ "b_f": "block", # Period history column — holds period tokens (subset of d). "d_h": "d", + # Scenario-lineage pair column (``dd_same_scenario``, recourse plan + # §6b Slice A) — holds period tokens (subset of d). + "d_other": "d", # ``d_first`` — column holding period tokens of "first" periods # (e.g. in ``_lifetime_expired_pairs_lf`` cascade). Subset of d. "d_first": "d", diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 7f5ecf39e..606da3063 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -26,6 +26,15 @@ * ``period_in_use_set`` — periods active in the active solve. ``realized_periods ∪ stochastic_branches ∪ invest_periods ∪ fix_storage_periods``. +* ``dd_same_scenario`` — (d, d_other) scenario-lineage pairs: some + scenario leaf-path contains both periods (recourse plan §6b + Slice A; ``specs/sliceA_lineage_frames_design.md`` §2.2). + All-pairs over ``period_in_use`` for deterministic solves. + Consumed by nothing until Slice B. +* ``pd_non_anticipativity`` — (d, b) period pairs whose INVEST + decisions must be tied when the ``non_anticipativity_invest_p/n`` + families land (Slice E). Provably empty for every currently + constructible solve (design §2.3). All helpers are lazy ``pl.LazyFrame`` chains; the public ``apply_branch_cluster`` entry collects once per emitted Param. @@ -38,7 +47,10 @@ realised-only for invest. This module does NOT introduce per-branch ``v_invest`` variables; it only emits the *operational* probability weights and the non-anticipativity gate that pin storage / online / -reserve dispatch across siblings. +reserve dispatch across siblings. The Slice A scenario-lineage +frames (``dd_same_scenario`` / ``pd_non_anticipativity``) are +descriptive bookkeeping only — the invariant still holds; branches do +not enter ``invest_periods`` in this slice. Workdir CSV reads (deferred to in-memory ``SolveContext`` per Δ.9+): @@ -655,6 +667,390 @@ def period_in_use_set_df( return df +# --------------------------------------------------------------------------- +# Scenario-lineage frames — Slice A of the recourse-investment plan +# (``specs/recourse_investment_plan.md`` §6b; design +# ``specs/sliceA_lineage_frames_design.md``). Consumed by NOTHING at +# HEAD — pure bookkeeping registered on FlexData for Slices B/D/E. +# --------------------------------------------------------------------------- + + +def _lineage_inputs( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> tuple[list[tuple[str, str]], list[str], dict[str, str]]: + """Collect ``(pb_rows, piu_list, tb_of)`` as plain-str structures. + + Shared input acquisition for :func:`dd_same_scenario_lf` and + :func:`pd_non_anticipativity_lf` (design §3.1.1): + + * ``pb_rows`` — ``period__branch`` (anchor d, member b) pairs in + CSV row order (period order). Guarded-ctx acquisition per the + ``dtttdt_from_source`` precedent (``_derived_params.py: + 6578-6605``): ``ctx.period_branch`` is trusted only when BOTH + columns are null-free — the ``d_anchor`` axis vocabulary is + empty by contract, so under enum activation the ctx frame's + anchor column is null-poisoned. Fallback reads the raw + provider/workdir CSV directly (NOT via + :func:`period_branch_pairs_lf`, whose ``.unique()`` does not + maintain row order — ``pos`` below is derived from the (d, d) + self-row order, which must stay the period order). + * ``piu_list`` — ``period_in_use`` members, de-duplicated in row + order. Order is content-irrelevant for the lineage frames + (``pos`` comes from the anchors); it only steers iteration. + * ``tb_of`` — SBTB member-period → time-branch map (ctx-first via + ``SolveContext.solve_branch_time_branch``, provider/workdir + fallback). + + All values are extracted to Python strings — comparisons between + ``b``-column (branch enum) and ``d``-column (period enum) values + must never happen Enum-vs-Utf8 or cross-Enum in polars. + """ + # --- period__branch -------------------------------------------------- + pb_rows: list[tuple[str, str]] = [] + got_pb = False + if ctx is not None: + pb_ctx = getattr(ctx, "period_branch", None) + if (pb_ctx is not None and pb_ctx.height > 0 + and pb_ctx["d_anchor"].null_count() == 0 + and pb_ctx["b"].null_count() == 0): + pb_rows = [(str(a), str(b)) for a, b in + zip(pb_ctx["d_anchor"].to_list(), + pb_ctx["b"].to_list())] + got_pb = True + if not got_pb and workdir is not None: + pb_path = Path(workdir) / "solve_data" / "period__branch.csv" + df = _provider_get(provider, pb_path) + if df is not None and df.height > 0 and len(df.columns) >= 2: + cols = df.columns + d_col = "period" if "period" in cols else cols[0] + b_col = "branch" if "branch" in cols else cols[1] + pb_rows = [(str(a), str(b)) for a, b in + zip(df[d_col].to_list(), df[b_col].to_list())] + pb_rows = list(dict.fromkeys(pb_rows)) + + # --- period_in_use --------------------------------------------------- + piu_df = period_in_use_set_lf(workdir, source, active_solve, + ctx=ctx, provider=provider).collect() + piu = list(dict.fromkeys( + str(v) for v in piu_df["d"].to_list() if v is not None)) + + # --- solve_branch__time_branch (SBTB) -------------------------------- + tb_of: dict[str, str] = {} + if ctx is not None: + sbtb = getattr(ctx, "solve_branch_time_branch", None) + if sbtb is not None and sbtb.height > 0: + tb_of = {str(d): str(tb) for d, tb in + zip(sbtb["d"].to_list(), + sbtb["time_branch"].to_list())} + if not tb_of and workdir is not None: + sbtb_path = (Path(workdir) / "solve_data" + / "solve_branch__time_branch.csv") + df = _provider_get(provider, sbtb_path) + if (df is not None and df.height > 0 + and "period" in df.columns and "branch" in df.columns): + tb_of = {str(d): str(tb) for d, tb in + zip(df["period"].to_list(), df["branch"].to_list())} + return pb_rows, piu, tb_of + + +def _classify_lineage( + pb_rows: list[tuple[str, str]], + piu: list[str], + tb_of: dict[str, str], +) -> tuple[list[str], dict[str, int], list[str], dict[str, str]]: + """Classify ``period_in_use`` into anchors / synthetic members. + + Returns ``(anchors, pos, synthetic, anchor_of)``: + + * ``anchors`` — real-named periods: ordered ``d`` values of the + ``(d, d)`` self-rows of ``period__branch`` restricted to + ``period_in_use`` (period order). When ``period__branch`` is + silent entirely (fixture-only loads without per-solve + scaffolding), every ``period_in_use`` member is an anchor — the + deterministic single-trunk fallback, mirroring + :func:`pd_branch_weight_lf`'s ``pb.height == 0`` branch. + * ``pos`` — anchor → period-order index. + * ``synthetic`` — ordered ``period_in_use`` members that are not + anchors (branch-period fan members with LP variables). + * ``anchor_of`` — synthetic member → its anchor (from the fan rows + ``d != b``). + + Failure semantics (design §3.1.1, mirroring ``dtttdt_from_source`` + ``_derived_params.py:6634-6650``): a synthetic ``period_in_use`` + member without a ``period__branch`` fan row, with an anchor that + has no in-use self-row, or without an SBTB time-branch entry means + the per-solve artefacts are inconsistent — RAISE instead of + silently mis-classifying branch periods onto the trunk (the same + silent-lineage failure class the 4.0.3 fan-out fix eliminated). + """ + if not pb_rows: + anchors = list(piu) + return anchors, {a: i for i, a in enumerate(anchors)}, [], {} + piu_set = dict.fromkeys(piu) + anchors = list(dict.fromkeys( + d for d, b in pb_rows if d == b and d in piu_set)) + pos = {a: i for i, a in enumerate(anchors)} + anchor_of: dict[str, str] = {} + for d, b in pb_rows: + if b != d and b not in anchor_of: + anchor_of[b] = d + synthetic = [m for m in piu if m not in pos] + for m in synthetic: + if m not in anchor_of: + raise ValueError( + "scenario lineage: period_in_use member " + f"{m!r} has no period__branch row (neither a (d, d) " + "self-row nor a fan row) — inconsistent per-solve " + "artefacts; cannot classify the period onto a " + "scenario leaf." + ) + if anchor_of[m] not in pos: + raise ValueError( + "scenario lineage: synthetic period_in_use member " + f"{m!r} is anchored to {anchor_of[m]!r}, which has no " + "in-use (d, d) self-row in period__branch — " + "inconsistent per-solve artefacts." + ) + if m not in tb_of: + raise ValueError( + "scenario lineage: period__branch declares branch " + f"period {m!r} (in period_in_use) but " + "solve_branch__time_branch has no entry for it — " + "cannot resolve the period's scenario (time-branch). " + "The per-solve emitter (emit_branch_weights_and_map) " + "writes this frame for every solve; its absence " + "indicates an inconsistent workdir/Provider state." + ) + return anchors, pos, synthetic, anchor_of + + +def _lineage_leaves( + anchors: list[str], + pos: dict[str, int], + synthetic: list[str], + anchor_of: dict[str, str], + tb_of: dict[str, str], +) -> list[list[str]]: + """Build the solve's scenario leaves (design §2.2). + + Trunk leaf = all anchors (period order). One leaf per time-branch + possessing at least one synthetic ``period_in_use`` member (F3 — + never iterate SBTB's own time-branches: SBTB also carries the + realized branch, whose synthetic-member set is empty). Per branch + leaf, the per-period representative follows the F6 authority rule: + the branch-local member is authoritative; a real anchor joins the + leaf only when it is strictly pre-reveal AND the branch has no + member for that period. + """ + leaves: list[list[str]] = [list(anchors)] + branch_tbs = dict.fromkeys(tb_of[m] for m in synthetic) + for br in branch_tbs: + members = [m for m in synthetic if tb_of[m] == br] + covered = dict.fromkeys(anchor_of[m] for m in members) + k = min(pos[anchor_of[m]] for m in members) + pre = [a for a in anchors if pos[a] < k and a not in covered] + leaf = sorted(pre + members, + key=lambda x: pos[x] if x in pos + else pos[anchor_of[x]]) + leaves.append(leaf) + return leaves + + +def _finalize_lineage_frame(rows: list[tuple[str, str]], + col_a: str, axis_a: str, + col_b: str, axis_b: str, + what: str) -> pl.LazyFrame: + """Utf8-construct → non-strict ``cast_dim`` → live null assertion + → deterministic Utf8-key sort (design §3.1.2 steps 4-5, F7). + + Constructing directly with a strict Enum schema would raise inside + polars at construction and make the null check dead code; the + Utf8-construct → non-strict-cast → assert sequence keeps the check + live. A nulled row after the cast means a period/branch token is + missing from the axis vocabulary — a wiring bug, not a data state. + When activation is off, ``cast_dim`` is a no-op and the assertion + is trivially true. + """ + raw = pl.DataFrame({col_a: [r[0] for r in rows], + col_b: [r[1] for r in rows]}, + schema={col_a: pl.Utf8, col_b: pl.Utf8}) + out = raw.select(cast_dim(pl.col(col_a), _enums, axis_a), + cast_dim(pl.col(col_b), _enums, axis_b)) + if out[col_a].null_count() or out[col_b].null_count(): + bad = sorted({v for col in (col_a, col_b) + for v, c in zip(raw[col].to_list(), + out[col].to_list()) + if c is None}) + raise ValueError( + f"{what}: axis-enum cast nulled token(s) {bad} — the " + "token is missing from the live axis vocabulary (wiring " + "bug; the 4.0.4 vocabulary splice includes every " + "continuation branch-period token)." + ) + out = out.sort(pl.col(col_a).cast(pl.Utf8), + pl.col(col_b).cast(pl.Utf8)) + return out.lazy() + + +def dd_same_scenario_lf( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.LazyFrame: + """``dd_same_scenario`` — (d, d_other) scenario-lineage pairs. + + "``d_other`` is on ``d``'s information path", formalized as: some + scenario leaf-path contains both periods (design §2.2). Symmetric + and reflexive; domain = ``period_in_use`` only (metadata-only fan + members and history periods excluded). For deterministic / + realized-only solves the frame degenerates to the all-pairs cross + product over ``period_in_use`` ("everything shares one path"). + + Binding consumer rule (F4, restated from the design): a lineage + semi-join may only REMOVE pairs whose BOTH ends are in + ``period_in_use``; any pair with either end outside the domain + passes through unconditionally (history-anchored walk rows). + + Returns a lazy ``(d, d_other)`` frame, deterministically sorted by + Utf8-cast keys. Typed empty frame when ``period_in_use`` is + empty. + """ + schema = {"d": schema_dtype(_enums, "d"), + "d_other": schema_dtype(_enums, "d")} + pb_rows, piu, tb_of = _lineage_inputs( + workdir, source, active_solve, ctx=ctx, provider=provider) + if not piu: + return _empty_lf(schema) + anchors, pos, synthetic, anchor_of = _classify_lineage( + pb_rows, piu, tb_of) + leaves = _lineage_leaves(anchors, pos, synthetic, anchor_of, tb_of) + seen: dict[tuple[str, str], None] = {} + for leaf in leaves: + for x in leaf: + for y in leaf: + seen[(x, y)] = None + if not seen: + return _empty_lf(schema) + return _finalize_lineage_frame(list(seen), "d", "d", "d_other", + "d_other", "dd_same_scenario") + + +def dd_same_scenario_df( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.DataFrame: + """Collect wrapper for :func:`dd_same_scenario_lf` — always a + typed frame, never ``None`` (design §2.5).""" + return dd_same_scenario_lf(workdir, source, active_solve, + ctx=ctx, provider=provider).collect() + + +def pd_non_anticipativity_lf( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, + branch_start: pl.DataFrame | None = None, +) -> pl.LazyFrame: + """``pd_non_anticipativity`` — (d, b) invest-NA period pairs. + + The period-level analog of dispatch ``db_pairs``: pairs whose + INVEST decisions must be tied when Slice E's + ``non_anticipativity_invest_p/n`` families land. ``(d, b)`` is + emitted iff ``b`` is a synthetic in-use fan member, ``d`` is its + anchor, and ``d`` lies STRICTLY before the information reveal of + ``b``'s branch (the branching period itself is NOT tied — recourse + invest at the branching period is per-scenario by owner decision, + plan §7). + + ``branch_start`` (optional, ``[time_branch, d_start]``) supplies + the authoritative reveal period per time-branch — the Slice E + hook. When ``None`` (the current-universe normal; the runner-side + ``branch_start_time_lists`` is not persisted), the fallback + ``k(br) = min over synthetic members m of br of pos(anchor(m))`` + is exact for every topology constructible at HEAD, under which the + emit condition is unsatisfiable — the frame is provably EMPTY for + every currently-constructible solve (design §2.3). On future + fanned-pre-reveal shapes the fallback deliberately under-detects; + Slice E must supply ``branch_start``. + + Returns a lazy ``(d, b)`` frame (``d`` = anchor / pin target on + the d axis, ``b`` = pinned sibling member on the branch axis, + mirroring dispatch ``db_pairs`` column roles). + """ + schema = {"d": schema_dtype(_enums, "d"), + "b": schema_dtype(_enums, "b")} + pb_rows, piu, tb_of = _lineage_inputs( + workdir, source, active_solve, ctx=ctx, provider=provider) + if not piu: + return _empty_lf(schema) + anchors, pos, synthetic, anchor_of = _classify_lineage( + pb_rows, piu, tb_of) + if not synthetic: + return _empty_lf(schema) + bs_of: dict[str, str] = {} + if branch_start is not None and branch_start.height > 0: + bs_of = {str(tb): str(d) for tb, d in + zip(branch_start["time_branch"].to_list(), + branch_start["d_start"].to_list())} + k_fallback: dict[str, int] = {} + for m in synthetic: + br = tb_of[m] + p = pos[anchor_of[m]] + if br not in k_fallback or p < k_fallback[br]: + k_fallback[br] = p + rows: list[tuple[str, str]] = [] + for m in synthetic: + br = tb_of[m] + if br in bs_of: + d_start = bs_of[br] + if d_start not in pos: + raise ValueError( + "pd_non_anticipativity: branch_start maps " + f"time-branch {br!r} to period {d_start!r}, which " + "is not an in-use anchor period." + ) + k = pos[d_start] + else: + k = k_fallback[br] + if pos[anchor_of[m]] < k: + rows.append((anchor_of[m], m)) + if not rows: + return _empty_lf(schema) + return _finalize_lineage_frame(rows, "d", "d", "b", "b", + "pd_non_anticipativity") + + +def pd_non_anticipativity_df( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, + branch_start: pl.DataFrame | None = None, +) -> pl.DataFrame: + """Collect wrapper for :func:`pd_non_anticipativity_lf` — always a + typed frame, never ``None`` (design §2.5).""" + return pd_non_anticipativity_lf( + workdir, source, active_solve, ctx=ctx, provider=provider, + branch_start=branch_start).collect() + + # --------------------------------------------------------------------------- # apply_branch_cluster — single-pass entry for the apply_derived_g # integration. Mutates ``flex_data`` in place. @@ -676,6 +1072,10 @@ def apply_branch_cluster( 1. ``period_branch_full`` (no dependencies; trivial CSV unroll). 2. ``period_in_use_set`` (depends on workdir or source). 3. ``dt_non_anticipativity`` (depends on realized + fix-storage CSVs). + 2b. ``dd_same_scenario`` / ``pd_non_anticipativity`` — Slice A + scenario-lineage frames (always-on; degenerate all-pairs / + empty content for deterministic solves; consumed by nothing + until Slice B/E). 4. ``pd_branch_weight`` (depends on period_branch_full + first_ts + period_in_use_set + branch weights). 5. ``pdt_branch_weight`` (depends on dt + period_branch_full + @@ -689,7 +1089,9 @@ def apply_branch_cluster( R-O6 invariant: this helper does NOT touch ``invest_periods`` or ``v_invest`` — only the operational dispatch-side weights and - the non-anticipativity gate. + the non-anticipativity gate. The Slice A lineage frames are + descriptive bookkeeping only; branches do not enter + ``invest_periods`` in this slice. """ dt = getattr(flex_data, "dt", None) @@ -710,6 +1112,16 @@ def apply_branch_cluster( if _new_dtn is not None or getattr(flex_data, "dt_non_anticipativity", None) is None: flex_data.dt_non_anticipativity = _new_dtn + # 2b. Scenario-lineage frames (Slice A — recourse plan §6b). + # Always-on; degenerate all-pairs content for deterministic + # solves; consumed by nothing until Slice B/E. Unconditional + # assignment — these fields have no CSV seed to preserve + # (builders always return a typed frame, never None). + flex_data.dd_same_scenario = dd_same_scenario_df( + workdir, source, active_solve, ctx=ctx, provider=provider) + flex_data.pd_non_anticipativity = pd_non_anticipativity_df( + workdir, source, active_solve, ctx=ctx, provider=provider) + # 3-4. Branch-weight Params (lazy ports of the previous eager # helpers in ``_derived_params.py``). _new_pd_bw = pd_branch_weight_param( @@ -760,6 +1172,10 @@ def apply_branch_cluster( "pdt_branch_weight_lf", "dt_non_anticipativity_lf", "period_branch_full_lf", + "dd_same_scenario_lf", + "dd_same_scenario_df", + "pd_non_anticipativity_lf", + "pd_non_anticipativity_df", "pd_branch_weight_param", "pdt_branch_weight_param", "dt_non_anticipativity_df", diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 67c200f16..89f9ee8f3 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -929,6 +929,15 @@ class FlexData: dt_non_anticipativity: pl.DataFrame | None = None # (d, t) — realised dispatch + fix-storage timesteps groupStochastic: pl.DataFrame | None = None # (g,) — groups enabling storage non-anticipativity period_in_use_set: pl.DataFrame | None = None # (d,) — periods active this solve (filters branches) + # Scenario-lineage frames (recourse plan §6b Slice A). Built by + # apply_branch_cluster for every solve; None only when the derived + # cascade never ran (hand-built FlexData). dd_same_scenario: + # (d, d_other) pairs sharing a scenario leaf-path (all-pairs over + # period_in_use for deterministic solves). pd_non_anticipativity: + # (d, b) invest-NA period pairs — empty until mid-horizon branch + # points exist (Slice E). + dd_same_scenario: pl.DataFrame | None = None # (d, d_other) + pd_non_anticipativity: pl.DataFrame | None = None # (d, b) # ─── Gap F final — handoff-path auxiliaries ─────────────────────────── # Per-solve in-memory carriers for fields that ``build_handoff_from_solution`` diff --git a/tests/engine_polars/test_derived_branch_lineage.py b/tests/engine_polars/test_derived_branch_lineage.py new file mode 100644 index 000000000..01c977be7 --- /dev/null +++ b/tests/engine_polars/test_derived_branch_lineage.py @@ -0,0 +1,498 @@ +"""Slice A — scenario-lineage frames: unit + integration tests. + +Design: ``specs/sliceA_lineage_frames_design.md`` (r3) §4/§5, tests +T1-T12. The two frames under test — ``dd_same_scenario (d, d_other)`` +and ``pd_non_anticipativity (d, b)`` — are pure bookkeeping consumed +by NOTHING at HEAD (recourse plan §6b Slice A; no behavior change). + +T1-T8 are solver-free: they seed a :class:`FlexDataProvider` with +in-memory frames (``period__branch`` cols ``period,branch``; +``period_in_use_set`` col ``period``; ``solve_branch__time_branch`` +cols ``period,branch``) and call the builders with a temp workdir. +T9 pins the region-filter pass-through. T10-T12 are integration +assertions on the 4.0.3 multi-period stochastic fixture +(``tests/fixtures/stoch_two_period.json``), including the first +stochastic dump/reload round-trip (T12 — pins the ``_copy_meta`` glob +behavior the always-on builders depend on). +""" +from __future__ import annotations + +import dataclasses + +import polars as pl +import pytest + +from flextool.engine_polars._derived_branch import ( + dd_same_scenario_df, + pd_non_anticipativity_df, +) +from flextool.engine_polars._flex_data_provider import FlexDataProvider + + +# --------------------------------------------------------------------------- +# Harness — provider seeding (design §5: keys per ``_provider_key`` = +# ``"solve_data/"``; builders require a non-None workdir to +# consult the provider). +# --------------------------------------------------------------------------- + + +def _mk_provider(pb=None, piu=None, sbtb=None) -> FlexDataProvider: + prov = FlexDataProvider() + if pb is not None: + prov.put("solve_data/period__branch", + pl.DataFrame({"period": [r[0] for r in pb], + "branch": [r[1] for r in pb]})) + if piu is not None: + prov.put("solve_data/period_in_use_set", + pl.DataFrame({"period": list(piu)})) + if sbtb is not None: + prov.put("solve_data/solve_branch__time_branch", + pl.DataFrame({"period": [r[0] for r in sbtb], + "branch": [r[1] for r in sbtb]})) + return prov + + +def _build(tmp_path, pb=None, piu=None, sbtb=None, branch_start=None): + prov = _mk_provider(pb=pb, piu=piu, sbtb=sbtb) + dd = dd_same_scenario_df(tmp_path, provider=prov) + pd_na = pd_non_anticipativity_df(tmp_path, provider=prov, + branch_start=branch_start) + return dd, pd_na + + +def _rows(df: pl.DataFrame) -> list[tuple[str, str]]: + return [(str(a), str(b)) for a, b in df.rows()] + + +# --------------------------------------------------------------------------- +# §4.1 fixture-shaped inputs (multi-period continuation fan-out) +# --------------------------------------------------------------------------- + +PB_41 = [ + ("p2035", "p2035"), + ("p2035", "p2035_rlz"), + ("p2035", "p2035_low"), + ("p2040", "p2040"), + ("p2040", "p2040_rlz"), + ("p2040", "p2040_low"), +] +PIU_41 = ["p2035", "p2035_low", "p2040", "p2040_low"] +SBTB_41 = [ + ("p2035_low", "low"), + ("p2040_low", "low"), + ("p2035", "rlz"), + ("p2040", "rlz"), +] + +# §4.1 — exactly 8 rows, in this order (Utf8-key sort). +DD_41 = [ + ("p2035", "p2035"), + ("p2035", "p2040"), + ("p2035_low", "p2035_low"), + ("p2035_low", "p2040_low"), + ("p2040", "p2035"), + ("p2040", "p2040"), + ("p2040_low", "p2035_low"), + ("p2040_low", "p2040_low"), +] + +# §4.3 — deterministic multi-period all-pairs. +DD_43 = [ + ("p2035", "p2035"), + ("p2035", "p2040"), + ("p2040", "p2035"), + ("p2040", "p2040"), +] + + +def test_t1_multi_period_fan(tmp_path): + """T1 (§4.1): the 8-row table verbatim, including row ORDER; + pd_non_anticipativity empty with schema (d, b).""" + dd, pd_na = _build(tmp_path, pb=PB_41, piu=PIU_41, sbtb=SBTB_41) + assert dd.columns == ["d", "d_other"] + assert _rows(dd) == DD_41 + assert pd_na.columns == ["d", "b"] + assert pd_na.height == 0 + + +def test_t2_single_period_fan(tmp_path): + """T2 (§4.2): 2day single-period shape — 4 singleton leaves → + exactly the 4 self-rows.""" + pb = [ + ("period1", "period1"), + ("period1", "period1_realized"), + ("period1", "period1_upper"), + ("period1", "period1_lower"), + ("period1", "period1_mid"), + ] + piu = ["period1", "period1_upper", "period1_lower", "period1_mid"] + sbtb = [ + ("period1_upper", "upper"), + ("period1_lower", "lower"), + ("period1_mid", "mid"), + ("period1", "realized"), + ] + dd, pd_na = _build(tmp_path, pb=pb, piu=piu, sbtb=sbtb) + assert _rows(dd) == [ + ("period1", "period1"), + ("period1_lower", "period1_lower"), + ("period1_mid", "period1_mid"), + ("period1_upper", "period1_upper"), + ] + assert pd_na.height == 0 + + +def test_t3_deterministic_multi_period(tmp_path): + """T3 (§4.3): PB self-rows only, SBTB empty → single trunk leaf → + all-pairs (4 rows).""" + pb = [("p2035", "p2035"), ("p2040", "p2040")] + dd, pd_na = _build(tmp_path, pb=pb, piu=["p2035", "p2040"]) + assert _rows(dd) == DD_43 + assert pd_na.height == 0 + + +def test_t4_rolling_realized_only(tmp_path): + """T4 (§4.4): rolling per-roll shape — metadata realized fan member + (∉ PIU) excluded → single self-row.""" + pb = [("period1", "period1"), ("period1", "period1_realized")] + sbtb = [("period1", "realized")] + dd, pd_na = _build(tmp_path, pb=pb, piu=["period1"], sbtb=sbtb) + assert _rows(dd) == [("period1", "period1")] + assert pd_na.height == 0 + + +def test_t5_degenerate_empty(tmp_path): + """T5 (§4.5): empty PIU → both frames empty, typed schemas, no + raise.""" + dd, pd_na = _build(tmp_path) + assert dd.columns == ["d", "d_other"] + assert dd.height == 0 + assert pd_na.columns == ["d", "b"] + assert pd_na.height == 0 + + +def test_t5b_no_period_branch_single_trunk(tmp_path): + """T5b (deterministic fallback, design §3.1 impl note): PB silent + entirely (fixture-only loads without per-solve scaffolding) but + PIU populated → single trunk leaf over PIU (all-pairs), no raise — + mirrors ``pd_branch_weight_lf``'s ``pb.height == 0`` branch.""" + dd, pd_na = _build(tmp_path, piu=["p2035", "p2040"]) + assert _rows(dd) == DD_43 + assert pd_na.height == 0 + + +def test_t6_failure_semantics_missing_sbtb(tmp_path): + """T6 (§3.1.1): PB fan rows with PIU synthetic members but no SBTB + frame → ValueError from BOTH builders (mirrors the + ``dtttdt_from_source`` failure semantics — a silent + treat-as-deterministic fallback would mis-classify branch periods + onto the trunk).""" + prov = _mk_provider(pb=PB_41, piu=PIU_41, sbtb=None) + with pytest.raises(ValueError, match="solve_branch__time_branch"): + dd_same_scenario_df(tmp_path, provider=prov) + with pytest.raises(ValueError, match="solve_branch__time_branch"): + pd_non_anticipativity_df(tmp_path, provider=prov) + + +def test_t6b_failure_semantics_orphan_synthetic(tmp_path): + """T6b: a PIU member with neither a self-row nor a fan row in a + populated PB → ValueError (inconsistent artefacts).""" + pb = [("p2035", "p2035")] + prov = _mk_provider(pb=pb, piu=["p2035", "p2040_low"], + sbtb=[("p2040_low", "low")]) + with pytest.raises(ValueError, match="no period__branch row"): + dd_same_scenario_df(tmp_path, provider=prov) + + +# --------------------------------------------------------------------------- +# T7 — future mid-horizon shapes (§4.6; not constructible end-to-end +# at HEAD — the branch fan always starts at the solve's first step). +# Pins the parameterized ``branch_start`` rule TODAY so Slice E +# inherits a tested rule instead of dead code. +# --------------------------------------------------------------------------- + +# Shape A — reveal at p2, NO pre-reveal copies. +PB_A = [ + ("p1", "p1"), ("p2", "p2"), ("p3", "p3"), + ("p2", "p2_low"), ("p3", "p3_low"), +] +PIU_A = ["p1", "p2", "p3", "p2_low", "p3_low"] +SBTB_A = [ + ("p2_low", "low"), ("p3_low", "low"), + ("p1", "rlz"), ("p2", "rlz"), ("p3", "rlz"), +] + +# §4.6 shape A — 17 unique rows (9 + 9 − shared (p1, p1)). +DD_A = [ + ("p1", "p1"), ("p1", "p2"), ("p1", "p2_low"), + ("p1", "p3"), ("p1", "p3_low"), + ("p2", "p1"), ("p2", "p2"), ("p2", "p3"), + ("p2_low", "p1"), ("p2_low", "p2_low"), ("p2_low", "p3_low"), + ("p3", "p1"), ("p3", "p2"), ("p3", "p3"), + ("p3_low", "p1"), ("p3_low", "p2_low"), ("p3_low", "p3_low"), +] + +# Shape B — reveal at p2 WITH fanned pre-reveal copy p1_low. +PB_B = PB_A + [("p1", "p1_low")] +PIU_B = ["p1", "p1_low", "p2", "p3", "p2_low", "p3_low"] +SBTB_B = SBTB_A + [("p1_low", "low")] + +# §4.6 shape B — 18 rows (R×R 9 ∪ L×L 9, disjoint). +_R_B = ["p1", "p2", "p3"] +_L_B = ["p1_low", "p2_low", "p3_low"] +DD_B = sorted([(x, y) for x in _R_B for y in _R_B] + + [(x, y) for x in _L_B for y in _L_B]) + + +def _branch_start_low_p2() -> pl.DataFrame: + return pl.DataFrame({"time_branch": ["low"], "d_start": ["p2"]}) + + +def test_t7_shape_a_pre_reveal_anchor(tmp_path): + """T7/A (§4.6): the pre-reveal anchor p1 joins L(low); 17-row + table; (p1, p2_low) present, (p2, p2_low) ABSENT; pd-NA empty + under both the fallback and branch_start=[(low, p2)].""" + dd, pd_na = _build(tmp_path, pb=PB_A, piu=PIU_A, sbtb=SBTB_A) + rows = _rows(dd) + assert rows == DD_A + assert ("p1", "p2_low") in rows and ("p2_low", "p1") in rows + assert ("p2", "p2_low") not in rows + assert pd_na.height == 0 + _, pd_na_bs = _build(tmp_path, pb=PB_A, piu=PIU_A, sbtb=SBTB_A, + branch_start=_branch_start_low_p2()) + assert pd_na_bs.height == 0 + + +def test_t7_shape_b_authority_rule(tmp_path): + """T7/B (§4.6, F6 authority rule): the fanned pre-reveal copy + p1_low displaces anchor p1 on L(low) — 18-row table with + (p1, p2_low) and (p1, p1_low) ABSENT from dd (the trunk↔branch + coupling at period 1 is expressed by pd_non_anticipativity, not + leaf membership); dd is branch_start-independent; pd-NA empty + under the min-anchor fallback (the documented under-detection) and + exactly [(p1, p1_low)] under branch_start=[(low, p2)].""" + dd, pd_na = _build(tmp_path, pb=PB_B, piu=PIU_B, sbtb=SBTB_B) + rows = _rows(dd) + assert rows == DD_B + assert len(rows) == 18 + assert ("p1", "p2_low") not in rows + assert ("p1", "p1_low") not in rows + # pd-NA: fallback under-detects by design (§2.3) — empty. + assert pd_na.height == 0 + dd_bs, pd_na_bs = _build(tmp_path, pb=PB_B, piu=PIU_B, sbtb=SBTB_B, + branch_start=_branch_start_low_p2()) + # dd content is branch_start-independent (the builder derives the + # leaf rule's k from the min-anchor; the table above was + # hand-derived under the TRUE reveal k — identical by §4.6). + assert _rows(dd_bs) == rows + assert _rows(pd_na_bs) == [("p1", "p1_low")] + + +def test_t7_symmetry_property(tmp_path): + """Symmetry as a tested property (design §2.2): for every shape, + (x, y) ∈ dd ⇔ (y, x) ∈ dd, and every PIU member has a self-row.""" + for pb, piu, sbtb in ( + (PB_41, PIU_41, SBTB_41), + (PB_A, PIU_A, SBTB_A), + (PB_B, PIU_B, SBTB_B), + ): + dd, _ = _build(tmp_path, pb=pb, piu=piu, sbtb=sbtb) + pairs = set(_rows(dd)) + assert {(y, x) for x, y in pairs} == pairs + for m in piu: + assert (m, m) in pairs + + +def test_t8_metadata_exclusion(tmp_path): + """T8: metadata-only fan members (realized-named ``_rlz`` and + zero-weight members absent from PIU) appear in NEITHER frame — + focused assert for failure localisation (keep-diagnostic-tests).""" + pb = PB_41 + [("p2035", "p2035_zero"), ("p2040", "p2040_zero")] + dd, pd_na = _build(tmp_path, pb=pb, piu=PIU_41, sbtb=SBTB_41) + tokens = ({v for r in _rows(dd) for v in r} + | {v for r in _rows(pd_na) for v in r}) + assert not [t for t in tokens if t.endswith(("_rlz", "_zero"))] + assert _rows(dd) == DD_41 + + +# --------------------------------------------------------------------------- +# T9 — region-filter pass-through (F8) +# --------------------------------------------------------------------------- + + +def _minimal_flex_data(): + from polar_high import Param + + from flextool.engine_polars.input import FlexData + + dt = pl.DataFrame({"d": ["p2035"], "t": ["t0001"]}) + val = dt.with_columns(value=pl.lit(1.0)) + n_val = pl.DataFrame({"n": ["city"], "d": ["p2035"], "t": ["t0001"], + "value": [1.0]}) + d_val = pl.DataFrame({"d": ["p2035"], "value": [1.0]}) + return FlexData( + dt=dt, + p_step_duration=Param(("d", "t"), val), + p_timestep_weight=Param(("d", "t"), val), + p_inflation_op=Param(("d",), d_val), + p_period_share=Param(("d",), d_val), + nodeBalance=pl.DataFrame({"n": ["city"]}), + p_inflow=Param(("n", "d", "t"), n_val), + p_penalty_up=Param(("n", "d", "t"), n_val), + p_penalty_down=Param(("n", "d", "t"), n_val), + ) + + +def test_t9_region_filter_pass_through(tmp_path): + """T9 (F8): ``_drop_master_rows`` with non-empty node/proc/cn/group + drop sets leaves both lineage frames row-identical — the generic + field sweep filters only frames carrying n/p/e/cn/g columns, and + the lineage frames carry only d/d_other/b.""" + from flextool.engine_polars._region_filter import _drop_master_rows + from flextool.engine_polars.input import FlexData + + dd, pd_na = _build(tmp_path, pb=PB_41, piu=PIU_41, sbtb=SBTB_41) + rd = _minimal_flex_data() + rd.dd_same_scenario = dd.clone() + rd.pd_non_anticipativity = pd_na.clone() + out = _drop_master_rows( + rd, + master_nodes={"city"}, + master_procs={"thermal"}, + master_cns=frozenset({"cn1"}), + master_groups=frozenset({"g1"}), + ) + assert out.dd_same_scenario.equals(dd) + assert out.pd_non_anticipativity.equals(pd_na) + # Secondary (trivially cheap): both fields are declared dataclass + # fields — they survive the splitter's ``dataclasses.replace`` + # shallow copies by stdlib contract. + names = {f.name for f in dataclasses.fields(FlexData)} + assert {"dd_same_scenario", "pd_non_anticipativity"} <= names + + +# --------------------------------------------------------------------------- +# T10-T12 — integration on the 4.0.3 multi-period stochastic fixture +# (same fixture machinery as test_stochastic_continuation_fanout.py). +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def stoch_wf(scenario_workdir): + return scenario_workdir("stoch", db_fixture="stoch_two_period") + + +@pytest.fixture(scope="module") +def det_wf(scenario_workdir): + return scenario_workdir("det", db_fixture="stoch_two_period") + + +@pytest.fixture(scope="module") +def stoch_data(stoch_wf): + from flextool.engine_polars import load_flextool + + return load_flextool(stoch_wf) + + +def test_t10_integration_stoch(stoch_data): + """T10: end-to-end ``load_flextool`` on the stoch workdir — the + §4.1 8-row table, pd-NA empty (pins provider seeding + + apply_derived_g wiring including enum activation, i.e. the §3.1.1 + null-guard actually engages).""" + dd = stoch_data.dd_same_scenario + assert dd is not None + assert _rows(dd) == DD_41 + pd_na = stoch_data.pd_non_anticipativity + assert pd_na is not None + assert pd_na.columns == ["d", "b"] + assert pd_na.height == 0 + + +def test_t10_integration_det(det_wf): + """T10 (det control): the §4.3 all-pairs table.""" + from flextool.engine_polars import load_flextool + + data = load_flextool(det_wf) + assert _rows(data.dd_same_scenario) == DD_43 + assert data.pd_non_anticipativity.height == 0 + + +def test_t11_enum_hygiene(stoch_data): + """T11: under ``load_flextool`` activation the d/d_other columns + carry the d-axis Enum (same dtype as ``period_in_use_set``'s d), + ``b`` carries the branch Enum (same as ``period_branch_full``'s + b), with zero nulls — pins the 4.0.4 continuation-token vocabulary + splice against regression.""" + dd = stoch_data.dd_same_scenario + d_dtype = stoch_data.period_in_use_set.schema["d"] + assert isinstance(d_dtype, pl.Enum) + assert dd.schema["d"] == d_dtype + assert dd.schema["d_other"] == d_dtype + assert dd["d"].null_count() == 0 + assert dd["d_other"].null_count() == 0 + cats = d_dtype.categories.to_list() + assert {"p2035_low", "p2040_low"} <= set(cats), ( + "continuation branch-period tokens missing from the d " + "vocabulary (4.0.4 splice regression)" + ) + pd_na = stoch_data.pd_non_anticipativity + assert pd_na.schema["d"] == d_dtype + b_dtype = stoch_data.period_branch_full.schema["b"] + assert isinstance(b_dtype, pl.Enum) + assert pd_na.schema["b"] == b_dtype + + +def test_t12_stoch_dump_reload_roundtrip(stoch_data, stoch_wf, tmp_path): + """T12 (F2, r3 — regression pin of the ``_copy_meta`` glob): dump + the stochastic FlexData with ``copy_meta_from``, assert + ``solve_branch__time_branch.csv`` is in the dumped tree (glob + copy), reload without a raise, and assert the lineage frames + REBUILD identically from the dumped workdir's CSVs (no §3.1.1 + inconsistency raise). First stochastic dump/reload round-trip in + the suite — existing round-trip tests cover deterministic fixtures + only. + + Path note (design §5 implementation note): a CSV-only reload + (dumped dir carries no Spine DB) skips the ENTIRE derived cascade + (``_apply_db_overrides`` gates on ``db_reader is not None``, + ``input.py:4679``), so every cascade-produced field — the lineage + frames, like their cousins ``period_branch_full`` / + ``dt_non_anticipativity`` — is None after ``load_flextool`` of a + dumped tree. The F2 substance ("the rebuild inputs all survive a + dump") is therefore pinned by running the builders directly + against the dumped CSVs.""" + from flextool.engine_polars import load_flextool + + out_dir = tmp_path / "dumped" + stoch_data.dump_csvs(out_dir, copy_meta_from=stoch_wf) + assert (out_dir / "solve_data" + / "solve_branch__time_branch.csv").exists(), ( + "_copy_meta glob no longer copies solve_branch__time_branch." + "csv — the always-on lineage builders would raise when the " + "cascade runs against the dumped tree" + ) + # Reload succeeds — no raise introduced by Slice A anywhere on the + # CSV-only load path. + redo = load_flextool(out_dir) + # Current-architecture pin: CSV-only reloads skip the derived + # cascade, so cascade-produced fields stay None (same as + # period_branch_full). If this flips, the cascade now runs on + # reload — re-point the equality leg below at ``redo`` directly. + assert redo.dd_same_scenario is None + assert redo.period_branch_full is None + # F2 substance: the builders, fed the DUMPED workdir's CSVs, + # rebuild both frames identically — i.e. every rebuild input + # survived the dump (period__branch.csv, period_in_use_set.csv, + # solve_branch__time_branch.csv). + prov = FlexDataProvider() + for stem in ("period__branch", "period_in_use_set", + "solve_branch__time_branch"): + p = out_dir / "solve_data" / f"{stem}.csv" + assert p.exists(), f"rebuild input {stem}.csv missing from dump" + prov.put(f"solve_data/{stem}", pl.read_csv(p)) + dd_rebuilt = dd_same_scenario_df(out_dir, provider=prov) + pd_rebuilt = pd_non_anticipativity_df(out_dir, provider=prov) + assert dd_rebuilt.columns == ["d", "d_other"] + assert _rows(dd_rebuilt) == _rows(stoch_data.dd_same_scenario) + assert pd_rebuilt.columns == ["d", "b"] + assert _rows(pd_rebuilt) == _rows(stoch_data.pd_non_anticipativity) From 19407fdb7619fa6361b3fe6a82da76b26d2741be Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 19:58:22 +0300 Subject: [PATCH 02/87] feat(stochastic): walker lineage capability, inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice B of the recourse-invest plan (specs/sliceB_walker_lineage_design.md). Add a keyword-only lineage=pl.DataFrame|None=None parameter to the shared period_walk_iterator, applied PRE-aggregation under the Slice A semi-join contract (remove (e,d,d_all) iff both ends in period_in_use and (d,d_all) not in lineage; history-anchored rows pass unconditionally). - _derived_walks: _apply_lineage_filter (anchor_col/dall_col params, fixed-direction lineage-side non-strict cast, mandatory .unique(), marker left-join) + _assert_lineage_castable null guard; zero-cost None path behind a single if. - Thread lineage=None through inventory #1-17 (cohort/variant/from_source chain in _derived_existing, _derived_npv, _derived_params) and apply the shared helper in the one non-walker pair-former #18 edd_divest_active_from_source (anchor_col="d_divest", dall_col="d"). - _derived_branch: check_recourse_npv_preconditions + assert_recourse_npv_preconditions — the three named NPV proof obligations (leaf-weight constancy, cohort sum-to-1, byte-equal fan-member year rows), shipped as a pure checker with the F4 soundness-scope docstring; added to __all__. Inert: every production call passes lineage=None; no flag, no consumer activation, no schema change, no autoscale entries, no LP change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 243 ++++++++++++++++++++ flextool/engine_polars/_derived_existing.py | 59 ++++- flextool/engine_polars/_derived_npv.py | 78 ++++++- flextool/engine_polars/_derived_params.py | 40 +++- flextool/engine_polars/_derived_walks.py | 116 ++++++++++ 5 files changed, 507 insertions(+), 29 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 606da3063..f1005a6a1 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1051,6 +1051,247 @@ def pd_non_anticipativity_df( branch_start=branch_start).collect() +# --------------------------------------------------------------------------- +# check_recourse_npv_preconditions — the three Slice A §2.2 NPV proof +# obligations, shipped as a pure checker (Slice B design §6). Exercised +# by tests only until the Slice D activation sites wire it at runtime. +# --------------------------------------------------------------------------- + + +def _read_solve_data_csv(workdir: Path | None, + provider: "object | None", + name: str) -> pl.DataFrame | None: + """Provider-first / workdir-CSV-fallback read of + ``solve_data/`` (the Slice A T12 harness acquisition idiom). + Returns ``None`` when neither source carries the file. + """ + if workdir is None: + return None + p = Path(workdir) / "solve_data" / name + df = _provider_get(provider, p) + if df is not None and df.height > 0: + return df + if p.exists(): + try: + df = pl.read_csv(p, infer_schema_length=0) + except Exception: + return None + if df.height > 0: + return df + return None + + +def _p_years_d_rows(workdir: Path | None, + provider: "object | None", + ) -> dict[str, float] | None: + """``{period: cumulative year}`` from ``solve_data/p_years_d.csv`` + (fallback ``period_with_history.csv``, ``param`` column) — the + canonical emitted CSVs that carry the fan-member byte-copies + (``_emit_solve_writers.py`` years-represented / period-years + copy loops).""" + df = _read_solve_data_csv(workdir, provider, "p_years_d.csv") + val_col = None + if df is not None and "period" in df.columns: + val_col = "value" if "value" in df.columns else df.columns[-1] + else: + df = _read_solve_data_csv(workdir, provider, + "period_with_history.csv") + if df is not None and "period" in df.columns: + val_col = "param" if "param" in df.columns else df.columns[-1] + if df is None or val_col is None: + return None + out: dict[str, float] = {} + for d, v in zip(df["period"].to_list(), df[val_col].to_list()): + if d is None or v is None: + continue + try: + out[str(d)] = float(v) + except (TypeError, ValueError): + continue + return out + + +def _p_years_represented_rows(workdir: Path | None, + provider: "object | None", + ) -> dict[str, list[tuple[str, float]]] | None: + """Ordered ``{period: [(year_label, width), ...]}`` from + ``solve_data/p_years_represented.csv`` (canonical columns + ``period, years_from_solve, p_years_from_solve, + p_years_represented`` — label = second column, width = last).""" + df = _read_solve_data_csv(workdir, provider, + "p_years_represented.csv") + if df is None or "period" not in df.columns or len(df.columns) < 2: + return None + label_col = df.columns[1] + width_col = df.columns[-1] + out: dict[str, list[tuple[str, float]]] = {} + for d, y, w in zip(df["period"].to_list(), df[label_col].to_list(), + df[width_col].to_list()): + if d is None or y is None or w is None: + continue + try: + out.setdefault(str(d), []).append((str(y), float(w))) + except (TypeError, ValueError): + continue + return out + + +def check_recourse_npv_preconditions( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, + weight_tol: float = 1e-9, + ) -> list[str]: + """Return violation messages for the three Slice A §2.2 NPV + obligations ((i) leaf-weight constancy, (ii) cohort sum-to-1, + (iii) byte-equal fan-member year rows). Empty list == all hold. + Deterministic message order. Raising wrapper: + :func:`assert_recourse_npv_preconditions`. + + These obligations pin the arithmetic that makes a SET-shaped + (unweighted) lineage filter numerically correct for + trunk-crossing windows and realized-anchor windows (recourse plan + §9.4) — on the invest AND divest side (variant-agnostic: they + constrain inputs, not any one walker). Runtime wiring is the + Slice D activation site (once per solve, before the first + lineage-passing call); until then this checker is exercised by + tests only. + + SOUNDNESS SCOPE (Slice B design F4/§6.2): obligation (iii) is + checked against the emitted solve_data CSVs (``p_years_d`` / + ``period_with_history`` / ``p_years_represented``). The check + underwrites the lineage-filter arithmetic IF AND ONLY IF the + walker's year/factor source is those same CSVs (the Slice D + year-threading prerequisite, Slice B design §6.4). If Slice D + instead anchor-maps branch-period years/factors inside the + walker, a FOURTH obligation is required: per fan member, the + anchor-map's resolved (year, factor) equals the CSV rows this + checker verified — without it the checker re-decouples from the + walker, recreating the §6.4 defect. + + Obligation (i)'s chains are defined against SBTB (which carries + the realized branch's real-named members too, so the trunk chain + is covered). A Slice E mid-horizon reveal will need to re-scope + constancy to post-reveal segments — out of scope here. + """ + violations: list[str] = [] + pb_rows, piu, tb_of = _lineage_inputs( + workdir, source, active_solve, ctx=ctx, provider=provider) + if not piu: + return [] + piu_set = dict.fromkeys(piu) + + wdf = pd_branch_weight_lf(workdir, source, active_solve, + ctx=ctx, provider=provider).collect() + weights: dict[str, float] = {} + for d, v in zip(wdf["d"].to_list(), wdf["value"].to_list()): + if d is not None and v is not None: + weights[str(d)] = float(v) + + # --- (i) per-leaf weight constancy ------------------------------------ + # Member chains = PIU representatives grouped by SBTB time-branch. + chains: dict[str, list[str]] = {} + for m in piu: + tb = tb_of.get(m) + if tb is not None: + chains.setdefault(tb, []).append(m) + for tb, members in chains.items(): + present = [(m, weights[m]) for m in members if m in weights] + missing = [m for m in members if m not in weights] + if missing and present: + violations.append( + "obligation (i) leaf-weight constancy: chain " + f"{tb!r} member(s) {missing} carry no pd_branch_weight " + "row while sibling members do." + ) + if len({v for _, v in present}) > 1: + detail = ", ".join(f"{m}={v!r}" for m, v in present) + violations.append( + "obligation (i) leaf-weight constancy: chain " + f"{tb!r} weights are not constant along the leaf: " + f"{detail}." + ) + + # --- (ii) cohort weights sum to 1 ------------------------------------- + # Fan rows (anchor -> members) in period__branch row order. + fan_of: dict[str, list[str]] = {} + for d, b in pb_rows: + if b != d: + fan_of.setdefault(d, []).append(b) + for a, members in fan_of.items(): + if a not in piu_set: + continue + cohort = [a] + [m for m in members if m in piu_set] + s = sum(weights.get(m, 0.0) for m in cohort) + if abs(s - 1.0) > weight_tol: + violations.append( + "obligation (ii) cohort weights sum to 1: anchor " + f"{a!r} cohort {cohort} weights sum to {s!r}." + ) + + # --- (iii) byte-equal fan-member year rows ---------------------------- + fan_pairs = [(a, m) for a, members in fan_of.items() + for m in members if a in piu_set and m in piu_set] + if fan_pairs: + pyd = _p_years_d_rows(workdir, provider) + pyr = _p_years_represented_rows(workdir, provider) + if pyd is None: + violations.append( + "obligation (iii) byte-equal year rows: cannot verify " + "— neither solve_data/p_years_d.csv nor " + "period_with_history.csv is readable while fan pairs " + "exist." + ) + if pyr is None: + violations.append( + "obligation (iii) byte-equal year rows: cannot verify " + "— solve_data/p_years_represented.csv is not readable " + "while fan pairs exist." + ) + for a, m in fan_pairs: + if pyd is not None: + ya, ym = pyd.get(a), pyd.get(m) + if ym != ya: + violations.append( + "obligation (iii) byte-equal year rows: " + f"p_years_d[{m!r}] = {ym!r} differs from its " + f"anchor p_years_d[{a!r}] = {ya!r}." + ) + if pyr is not None: + ra = pyr.get(a, []) + rm = pyr.get(m, []) + if rm != ra: + violations.append( + "obligation (iii) byte-equal year rows: " + f"p_years_represented rows of {m!r} ({rm!r}) " + f"differ from its anchor {a!r} ({ra!r})." + ) + return violations + + +def assert_recourse_npv_preconditions( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, + weight_tol: float = 1e-9, + ) -> None: + """Raising wrapper for :func:`check_recourse_npv_preconditions`.""" + violations = check_recourse_npv_preconditions( + workdir, source, active_solve, ctx=ctx, provider=provider, + weight_tol=weight_tol) + if violations: + raise ValueError( + "recourse NPV preconditions violated:\n " + + "\n ".join(violations) + ) + + # --------------------------------------------------------------------------- # apply_branch_cluster — single-pass entry for the apply_derived_g # integration. Mutates ``flex_data`` in place. @@ -1182,4 +1423,6 @@ def apply_branch_cluster( "period_branch_full_df", "period_in_use_set_df", "apply_branch_cluster", + "check_recourse_npv_preconditions", + "assert_recourse_npv_preconditions", ] diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index 8c5a2d505..49f53475f 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -387,6 +387,8 @@ def edd_history_choice_lf(source: "InputSource", period_with_history: list[str], period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_choice``. @@ -394,10 +396,14 @@ def edd_history_choice_lf(source: "InputSource", ``lifetime_method = reinvest_choice``:: keep iff pdy[d] >= pdy[d_h] AND pdy[d] < pdy[d_h] + life[e, d_h] + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, - method="reinvest_choice", bounded=True, workdir=workdir) + method="reinvest_choice", bounded=True, workdir=workdir, + lineage=lineage) def edd_history_automatic_lf(source: "InputSource", @@ -405,6 +411,8 @@ def edd_history_automatic_lf(source: "InputSource", period_with_history: list[str], period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_automatic`` — entities with ``lifetime_method = reinvest_automatic``. @@ -412,10 +420,14 @@ def edd_history_automatic_lf(source: "InputSource", Mirror of ``invest_divest_sets.py:245-246``:: keep iff pdy[d] >= pdy[d_h] + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, - method="reinvest_automatic", bounded=False, workdir=workdir) + method="reinvest_automatic", bounded=False, workdir=workdir, + lineage=lineage) def edd_history_no_investment_lf(source: "InputSource", @@ -423,15 +435,21 @@ def edd_history_no_investment_lf(source: "InputSource", period_with_history: list[str], period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_no_investment``. Mirror of ``invest_divest_sets.py:247-248``: same predicate as ``edd_history_choice`` but for the ``no_investment`` cohort. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, - method="no_investment", bounded=True, workdir=workdir) + method="no_investment", bounded=True, workdir=workdir, + lineage=lineage) def _edd_history_lf_for(source: "InputSource", @@ -442,9 +460,13 @@ def _edd_history_lf_for(source: "InputSource", method: str, bounded: bool, workdir: Path | None = None, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Internal helper: build edd_history sub-set for a single lifetime_method cohort. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_with_history or not period_in_use: return pl.LazyFrame(schema={ @@ -478,14 +500,14 @@ def _edd_history_lf_for(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, life_lf=life_lf, factor_side=None, - workdir=workdir) + workdir=workdir, lineage=lineage) else: walk = period_walk_iterator( source, active_solve, anchor, period_in_use, period_in_use, window_method=WindowMethod.UNBOUNDED_FORWARD, life_lf=None, factor_side=None, - workdir=workdir) + workdir=workdir, lineage=lineage) return walk.pipe(rename_to_axis, {"d": "d_history", "d_all": "d"}) @@ -494,6 +516,8 @@ def edd_history_lf(source: "InputSource", period_with_history: list[str], period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for the union ``edd_history`` set. @@ -502,6 +526,9 @@ def edd_history_lf(source: "InputSource", walks (:func:`edd_history_choice_lf`, :func:`edd_history_automatic_lf`, :func:`edd_history_no_investment_lf`). + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_with_history or not period_in_use: return pl.LazyFrame(schema={ @@ -512,13 +539,13 @@ def edd_history_lf(source: "InputSource", parts = [ edd_history_choice_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage), edd_history_automatic_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage), edd_history_no_investment_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage), ] return pl.concat(parts, how="vertical").unique() @@ -669,6 +696,8 @@ def edd_invest_set_lf(source: "InputSource", period_with_history: list[str], period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_history_invest`` filtered to ``(e, d_invest) ∈ ed_invest``. @@ -682,10 +711,13 @@ def edd_invest_set_lf(source: "InputSource", every entity with a recognised lifetime_method; this helper filters to those whose ``d_history`` (renamed ``d_invest``) is also a valid invest decision in the current solve. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ edd = edd_history_lf(source, active_solve, period_with_history, period_in_use, - workdir) + workdir, lineage=lineage) inv_pairs = ed_invest_lf.pipe(rename_to_axis, {"d": "d_history"}) return (edd .join(inv_pairs, on=["e", "d_history"], how="inner") @@ -698,6 +730,8 @@ def edd_invest_lookback_set_lf(source: "InputSource", ed_invest_lf: pl.LazyFrame, period_in_use: list[str], workdir: Path | None = None, + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_invest_lookback_set``. @@ -722,6 +756,9 @@ def edd_invest_lookback_set_lf(source: "InputSource", The eager helper now delegates to this lazy port — the previous Python ``for r in out.iter_rows`` lifetime gate is replaced with a fully lazy join + filter on the shared walker. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_in_use: return pl.LazyFrame(schema={ @@ -781,7 +818,7 @@ def edd_invest_lookback_set_lf(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.STRICT_LOOKBACK_BOUNDED, life_lf=life_lf, factor_side=None, - workdir=workdir) + workdir=workdir, lineage=lineage) # Unbounded cohort — strict-lookback only, no lifetime cap. unbounded_anchor = anchor_lf.join(unbounded_e, on="e", how="inner") @@ -795,7 +832,7 @@ def edd_invest_lookback_set_lf(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.STRICT_LOOKBACK_UNBOUNDED, life_lf=None, factor_side=None, - workdir=workdir) + workdir=workdir, lineage=lineage) return (pl.concat([bounded_walk, unbounded_walk], how="vertical") .pipe(rename_to_axis, {"d": "d_invest", "d_all": "d"}) diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 80af8cc52..52c43af48 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -526,6 +526,7 @@ def period_walk_iterator( bounded: bool, life_lf: pl.LazyFrame, factor_side: str, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Δ.5-era public alias delegating to :mod:`._derived_walks`. @@ -536,7 +537,10 @@ def period_walk_iterator( :class:`._derived_walks.WindowMethod` enum. See :func:`._derived_walks.period_walk_iterator` for the canonical - semantics. + semantics. ``bounded`` and ``lineage`` are orthogonal — the bool + selects the window predicate, ``lineage`` restricts the + ``(d, d_all)`` pair domain (Slice B; always ``None`` until the + recourse flag lands, Slice C/D); the keyword is forwarded verbatim. """ from ._derived_walks import ( period_walk_iterator as _walk, @@ -547,7 +551,7 @@ def period_walk_iterator( source, active_solve, ed_lf, period_in_use, period_universe, window_method=method, life_lf=life_lf, - factor_side=factor_side) + factor_side=factor_side, lineage=lineage) # --------------------------------------------------------------------------- @@ -692,6 +696,8 @@ def npv_invest_discounted_lf( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Variant 1: ``ed_entity_annual_discounted`` (invest-side NPV). @@ -705,6 +711,9 @@ def npv_invest_discounted_lf( multiple methods; each contributes a separate sum. See ``entity_annual_calc_params.py:222-251``. + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). + Returns lazy ``[e, d, value]``. """ if not period_invest: @@ -748,11 +757,13 @@ def npv_invest_discounted_lf( bounded = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=True, life_lf=life_lf, factor_side="inv") + bounded=True, life_lf=life_lf, factor_side="inv", + lineage=lineage) unbounded = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=False, life_lf=life_lf, factor_side="inv") + bounded=False, life_lf=life_lf, factor_side="inv", + lineage=lineage) # Combine. bounded = bounded.rename({"factor": "factor_b"}) @@ -790,6 +801,8 @@ def npv_divest_discounted_lf( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Variant 2: ``ed_entity_annual_divest_discounted`` (divest-side NPV). @@ -801,6 +814,9 @@ def npv_divest_discounted_lf( and the gate is restricted to e ∈ node ∪ process (mirrors L266-285 — no fallback for unclassified entities). + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). + Returns lazy ``[e, d, value]``. """ if not period_invest: @@ -833,7 +849,8 @@ def npv_divest_discounted_lf( walk = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=True, life_lf=life_lf, factor_side="inv") + bounded=True, life_lf=life_lf, factor_side="inv", + lineage=lineage) return (ed_anchor_lf .join(ann_lf, on=["e", "d"], how="left") @@ -880,6 +897,8 @@ def lifetime_fixed_cost_invest_lf( period_with_history: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Variant 3: ``ed_lifetime_fixed_cost`` (invest-side lifetime FC). @@ -891,6 +910,9 @@ def lifetime_fixed_cost_invest_lf( (choice/no_invest → bounded; automatic → unbounded), with ops_factor (not inv_factor) and edEntity_lifetime as the bound. See L292-321. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_with_history: return pl.LazyFrame(schema={ @@ -924,11 +946,13 @@ def lifetime_fixed_cost_invest_lf( bounded = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=True, life_lf=life_lf, factor_side="ops") + bounded=True, life_lf=life_lf, factor_side="ops", + lineage=lineage) unbounded = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=False, life_lf=life_lf, factor_side="ops") + bounded=False, life_lf=life_lf, factor_side="ops", + lineage=lineage) bounded = bounded.rename({"factor": "factor_b"}) unbounded = unbounded.rename({"factor": "factor_u"}) @@ -965,6 +989,8 @@ def lifetime_fixed_cost_divest_lf( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Variant 4: ``ed_lifetime_fixed_cost_divest`` (divest-side FC). @@ -974,6 +1000,9 @@ def lifetime_fixed_cost_divest_lf( where ``life`` is raw lifetime and ``inv_factor`` (NOT ops_factor) is used — mod L1651 asymmetry. See L325-348. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_invest: return pl.LazyFrame(schema={ @@ -1003,7 +1032,8 @@ def lifetime_fixed_cost_divest_lf( walk = period_walk_iterator( source, active_solve, ed_anchor_lf, period_in_use, period_universe, - bounded=True, life_lf=life_lf, factor_side="inv") + bounded=True, life_lf=life_lf, factor_side="inv", + lineage=lineage) return (ed_anchor_lf .join(fc_lf, on=["e", "d"], how="left") @@ -1093,6 +1123,8 @@ def ed_entity_annual_discounted_from_source( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> "Param | None": """Public entry: ``ed_entity_annual_discounted[e, d]``. @@ -1104,12 +1136,16 @@ def ed_entity_annual_discounted_from_source( returns a non-None frame, so emitting the zero rows is required for the lazy result to match the seed on fixtures where every (e, d) value is zero (e.g. ``work_wind_battery_invest``). + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_invest: return None out = (npv_invest_discounted_lf( source, active_solve, - period_invest, period_in_use, period_universe) + period_invest, period_in_use, period_universe, + lineage=lineage) .select("e", "d", "value") .sort("e", "d") .collect()) @@ -1124,17 +1160,23 @@ def ed_entity_annual_divest_discounted_from_source( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> "Param | None": """Public entry: ``ed_entity_annual_divest_discounted[e, d]``. Unfiltered ``entityDivest × period_invest`` — see the rationale on :func:`ed_entity_annual_discounted_from_source`. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_invest: return None out = (npv_divest_discounted_lf( source, active_solve, - period_invest, period_in_use, period_universe) + period_invest, period_in_use, period_universe, + lineage=lineage) .select("e", "d", "value") .sort("e", "d") .collect()) @@ -1149,17 +1191,23 @@ def ed_lifetime_fixed_cost_from_source( period_with_history: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> "Param | None": """Public entry: ``ed_lifetime_fixed_cost[e, d]``. Unfiltered ``entity × period_with_history`` — see the rationale on :func:`ed_entity_annual_discounted_from_source`. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_with_history: return None out = (lifetime_fixed_cost_invest_lf( source, active_solve, - period_with_history, period_in_use, period_universe) + period_with_history, period_in_use, period_universe, + lineage=lineage) .select("e", "d", "value") .sort("e", "d") .collect()) @@ -1174,17 +1222,23 @@ def ed_lifetime_fixed_cost_divest_from_source( period_invest: list[str], period_in_use: list[str], period_universe: list[str], + *, + lineage: pl.DataFrame | None = None, ) -> "Param | None": """Public entry: ``ed_lifetime_fixed_cost_divest[e, d]``. Unfiltered ``entityDivest × period_invest`` — see the rationale on :func:`ed_entity_annual_discounted_from_source`. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if not period_invest: return None out = (lifetime_fixed_cost_divest_lf( source, active_solve, - period_invest, period_in_use, period_universe) + period_invest, period_in_use, period_universe, + lineage=lineage) .select("e", "d", "value") .sort("e", "d") .collect()) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 5359ad349..92383f90d 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -3730,6 +3730,7 @@ def edd_invest_lookback_set_from_source(source: "InputSource", workdir: Path | None = None, *, provider: "object | None" = None, + lineage: pl.DataFrame | None = None, ) -> pl.DataFrame | None: """Build the strict-lookback (e, d_invest, d) tuples used by the user-constraint LHS prebuilt-capacity term (mod L2885-2898). @@ -3753,6 +3754,9 @@ def edd_invest_lookback_set_from_source(source: "InputSource", new ``STRICT_LOOKBACK_*`` modes. The previous eager ``for r in out.iter_rows`` lifetime gate is replaced with a fully lazy join + filter on the shared walker. + + ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; + always ``None`` until the recourse flag lands (Slice C/D). """ if ed_invest is None or ed_invest.height == 0: return pl.DataFrame(schema={ @@ -3778,7 +3782,8 @@ def edd_invest_lookback_set_from_source(source: "InputSource", from ._derived_existing import edd_invest_lookback_set_lf ed_invest_lf = ed_invest.lazy().select("e", "d") return edd_invest_lookback_set_lf( - source, active_solve, ed_invest_lf, periods, workdir).collect() + source, active_solve, ed_invest_lf, periods, workdir, + lineage=lineage).collect() def edd_divest_active_from_source(source: "InputSource", @@ -3786,11 +3791,23 @@ def edd_divest_active_from_source(source: "InputSource", pd_divest: pl.DataFrame | None, *, provider: "object | None" = None, + lineage: pl.DataFrame | None = None, ) -> pl.DataFrame | None: """Build the active-divest (p, d_divest, d) tuples — see audit §3.7.3. pd_divest ⊆ ed_divest with ``p ∈ process``; the active set further filters d_divest ≤ d using year ordering. + + ``lineage`` — the one non-walker cross-period pair-former + (Slice B design §7.0, inventory #18): when not ``None``, the + shared :func:`._derived_walks._apply_lineage_filter` is applied to + the ``(d_divest, d)`` pairs with ``anchor_col="d_divest"``, + ``dall_col="d"``, under the same Slice A semi-join contract. The + ``period_in_use`` list the filter tests membership against is the + function-local source-derived ``periods`` list below (NOT the + workdir-CSV PIU — this helper resolves its period domain from the + source only). Always ``None`` until the recourse flag lands + (Slice C/D). """ if pd_divest is None or pd_divest.height == 0: return pl.DataFrame(schema={ @@ -3816,11 +3833,22 @@ def edd_divest_active_from_source(source: "InputSource", pdd_lf = pd_divest.lazy().pipe(rename_to_axis, {"d": "d_divest"}) yr_div = pyd_lf.pipe(rename_to_axis, {"d": "d_divest", "yr": "yr_divest"}) yr_d = pyd_lf.rename({"yr": "yr"}) - out = (pdd_lf - .join(period_lf, how="cross") - .join(yr_div, on="d_divest", how="inner") - .join(yr_d, on="d", how="inner") - .filter(pl.col("yr_divest") <= pl.col("yr")) + chain = (pdd_lf + .join(period_lf, how="cross") + .join(yr_div, on="d_divest", how="inner") + .join(yr_d, on="d", how="inner") + .filter(pl.col("yr_divest") <= pl.col("yr"))) + if lineage is not None: + from ._derived_walks import ( + _apply_lineage_filter, + _assert_lineage_castable, + ) + d_dtype = chain.collect_schema().get("d", pl.Utf8) + _assert_lineage_castable(lineage, d_dtype) + chain = _apply_lineage_filter(chain, lineage, periods, d_dtype, + anchor_col="d_divest", + dall_col="d") + out = (chain .select("p", "d_divest", "d") .sort("p", "d_divest", "d") .collect()) diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index ddb7265d8..72b63c07c 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -45,6 +45,19 @@ weight column to sum (``factor_side``: ``"inv"`` / ``"ops"`` → inflation factor), or skip aggregation entirely and return the ``(e, d, d_all)`` triples (``factor_side=None``) for set-shape outputs. + +Scenario-lineage filter (recourse plan §6b Slice B) +--------------------------------------------------- + +``lineage`` optionally restricts the ``(d, d_all)`` pair domain to the +Slice A ``dd_same_scenario`` contract +(``specs/sliceA_lineage_frames_design.md`` §2.2/§2.5.3) BEFORE the +``factor_side`` aggregation — remove a walk row ``(e, d, d_all)`` iff +``d ∈ period_in_use`` AND ``d_all ∈ period_in_use`` AND +``(d, d_all) ∉ lineage``; any row with either end outside +``period_in_use`` (history-anchored rows) passes unconditionally. +Every production caller passes ``lineage=None`` (no filtering) until +the Slice C/D recourse activation lands. """ from __future__ import annotations @@ -90,6 +103,86 @@ class WindowMethod(enum.Enum): BOUNDED_INCLUSIVE_LOOKBACK = "bounded_inclusive_lookback" +def _assert_lineage_castable(lineage: pl.DataFrame, + d_dtype: pl.DataType) -> None: + """Raise ``ValueError`` when any lineage token nulls under a + non-strict cast to the walk's ``d`` dtype. + + A silently-nulled token would make the marker left-join miss and + DELETE a pair that IS in the lineage frame — exactly the + silent-lineage failure class the recourse program keeps + eliminating. Mirrors the Slice A ``_finalize_lineage_frame`` + live-assert idiom (``_derived_branch.py``). + + Empirical polars 1.40.1 behavior this guard exists for (Slice B + design W5(c)): a NON-STRICT cast between two different Enum + vocabularies (and Utf8 → Enum) NULLS every token missing from the + target vocabulary instead of raising — so it is THIS guard that + raises, never polars itself. + """ + bad: list[str] = [] + for col in ("d", "d_other"): + raw = lineage[col] + cast = raw.cast(d_dtype, strict=False) + bad.extend(str(v) for v, c in zip(raw.to_list(), cast.to_list()) + if c is None) + if bad: + raise ValueError( + "period_walk_iterator lineage filter: token(s) " + f"{sorted(set(bad))} in the lineage frame are not castable " + f"to the walk's d dtype {d_dtype!r} — a silently-nulled " + "token would delete lineage pairs from the walk (Slice B " + "design §4.2 null guard)." + ) + + +def _apply_lineage_filter( + walk: pl.LazyFrame, + lineage: pl.DataFrame, + period_in_use: list[str], + d_dtype: pl.DataType, + *, + anchor_col: str = "d", + dall_col: str = "d_all", + ) -> pl.LazyFrame: + """Slice A semi-join contract (sliceA design §2.2/§2.5.3): + remove ``(anchor, other)`` iff BOTH ends ∈ ``period_in_use`` AND + the pair is not in ``lineage``; rows with either end outside + ``period_in_use`` (history-anchored walk rows) pass through + unconditionally. + + Dtype alignment is fixed-direction: the LINEAGE side is cast + (non-strict) to the walk's ``d`` dtype; the walk's column dtypes + are invariant under the filter. ``align_join_dtypes`` was + evaluated for this seam and REJECTED (Slice B design §4.2 F6): its + preference rule casts toward whichever side is Enum, so on the + raw-CSV cascade path (walk ``d`` = Utf8, lineage = Enum) it would + mutate the walk's output dtype mid-pipeline and null-poison walk + tokens outside the lineage vocabulary (history anchors are exactly + such candidates). + + ``anchor_col`` / ``dall_col`` parametrise the walk-side column + names so the one helper also serves the non-walker pair-former + ``edd_divest_active_from_source`` (``(d_divest, d)`` frame — + Slice B design §7.0); both resolve to the d-axis vocabulary. + """ + lin = (lineage.lazy() + .select( + pl.col("d").cast(d_dtype, strict=False) + .alias(anchor_col), + pl.col("d_other").cast(d_dtype, strict=False) + .alias(dall_col)) + .unique() # mandatory: a duplicated + # pair must not fan the join + .with_columns(_lin=pl.lit(True))) + in_piu = (pl.col(anchor_col).cast(pl.Utf8).is_in(period_in_use) + & pl.col(dall_col).cast(pl.Utf8).is_in(period_in_use)) + return (walk + .join(lin, on=[anchor_col, dall_col], how="left") + .filter(pl.col("_lin").fill_null(False) | ~in_piu) + .drop("_lin")) + + def period_walk_iterator( source: "InputSource", active_solve: str | None, @@ -101,6 +194,7 @@ def period_walk_iterator( life_lf: pl.LazyFrame | None, factor_side: str | None, workdir = None, + lineage: pl.DataFrame | None = None, ) -> pl.LazyFrame: """Lazy per-(e, d) walk over ``period_in_use``, gated by lifetime. @@ -140,6 +234,21 @@ def period_walk_iterator( callers don't pass this (they're called from the apply_npv boundary which has consumed the workdir already); Cluster B's invest-history callers do. + lineage + Optional ``(d, d_other)`` scenario-lineage frame + (``flex_data.dd_same_scenario``, Slice A — see + ``specs/sliceA_lineage_frames_design.md`` §2.2/§2.5.3). When + provided, the ``(d, d_all)`` pairs are filtered BEFORE the + ``factor_side`` aggregation / set-shape dedup under the + Slice A semi-join contract: remove a walk row + ``(e, d, d_all)`` iff ``d ∈ period_in_use`` AND + ``d_all ∈ period_in_use`` AND ``(d, d_all) ∉ lineage``; any + row with either end outside ``period_in_use`` passes through + unconditionally. ``None`` (the default, and every production + call at HEAD) applies no filtering — the hot path gains one + pointer comparison. The two empty-input early returns below + deliberately do not consult ``lineage`` (with no pairs there + is nothing to filter). Returns ------- @@ -241,6 +350,13 @@ def period_walk_iterator( .filter(pl.col("yr_dall") < pl.col("yr_d") + pl.col("life")) ) + # Scenario-lineage filter (Slice B) — PRE-aggregation, so the + # factor-side Σ over d_all only ever sees same-scenario pairs. + if lineage is not None: + _assert_lineage_castable(lineage, ed_d_dtype) + walk = _apply_lineage_filter(walk, lineage, + period_in_use, ed_d_dtype) + if factor_side is None: return (walk .select("e", "d", "d_all") From dd20b5e24ce9ce51e7094129015cabfe4d9c0f91 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 19:58:33 +0300 Subject: [PATCH 03/87] test(stochastic): walker lineage capability + NPV obligation suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice B test suite (specs/sliceB_walker_lineage_design.md §9). Capability harness (solver-free, hand-built frames + stub source): - W1 set-shape (history passes, cross-leaf both-ends-PIU pairs removed) - W2 factor-side PRE-aggregation proof (sums 7.0/3.0/4.0 vs unfiltered 7.0 — a post-aggregation filter cannot produce 3.0/4.0) - W3 lineage=None 12-row unfiltered pin - W4/W4b empty-frame contract (set: 4 history rows; factor: single (h2020,7.0) row, anchors absent not zeroed) - W5a duplicate-pair .unique() guard; W5b uncastable-token ValueError; W5c Enum x Enum missing-token raises / all-present identical Stochastic fixture pins (solver-required fixture, solver-free asserts): - W6 edd_invest_set today-bug witness ((wind,p2040,p2040_low) absent = erratum E1 first-stage-visibility break) - W7 edd/divest non-vacuity + repeat-determinism; NPV variant vacuity pins + invest-method overlay leg - P1/P2 obligation checker clean on stoch/deterministic workdirs - P3a-d hand-seeded obligation violations (i)/(ii)/(iii) - P4 walker year-source blindness diagnostic (erratum E1 witness pair) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../engine_polars/test_period_walk_lineage.py | 758 ++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 tests/engine_polars/test_period_walk_lineage.py diff --git a/tests/engine_polars/test_period_walk_lineage.py b/tests/engine_polars/test_period_walk_lineage.py new file mode 100644 index 000000000..cec680102 --- /dev/null +++ b/tests/engine_polars/test_period_walk_lineage.py @@ -0,0 +1,758 @@ +"""Slice B — pre-aggregation lineage support in the shared period walker. + +Design: ``specs/sliceB_walker_lineage_design.md`` (r2), honoring the +Slice A gating contract (``specs/sliceA_lineage_frames_design.md`` +§2.2/§2.5 as corrected by erratum E1). + +Two layers (design §9): + +* **Capability harness (W1-W5)** — fully solver-free, hand-built + frames + a minimal stub source. These pin the filter semantics + (set-shape W1, PRE-aggregation W2, unfiltered W3, empty-frame W4/W4b, + guard cases W5a/b/c) exactly against the design's expected tables. +* **Stochastic fixture pins (W6, W7, P1, P2, P4)** — *solver-required + fixture, solver-free assertions*: ``scenario_workdir`` materialises + the workdir by running the full cascade (incl. HiGHS); the tests then + make pure-frame assertions against it — no golden depends on them. +* **Proof-obligation tests (P1-P4)** — the three named NPV obligations + from Slice A §2.2 consequence 2, exercised via + :func:`check_recourse_npv_preconditions` (hand-seeded providers for + P3, live workdir for P1/P2/P4). + +Every production call passes ``lineage=None``; this slice is inert +plumbing. The filter is exercised here only. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import polars as pl +import pytest + +from flextool.engine_polars import _derived_npv as npv +from flextool.engine_polars._derived_branch import ( + check_recourse_npv_preconditions, +) +from flextool.engine_polars._derived_existing import ( + edd_history_lf, + edd_invest_lookback_set_lf, + edd_invest_set_lf, +) +from flextool.engine_polars._derived_params import ( + _p_years_d_lf, + _period_in_use_set, + _periodAll_from_source, + _read_active_solve, + _read_period_with_history, + edd_divest_active_from_source, +) +from flextool.engine_polars._derived_walks import ( + WindowMethod, + period_walk_iterator, +) +from flextool.engine_polars._flex_data_provider import FlexDataProvider +from flextool.engine_polars._input_source import seed_provider_from_dir + + +# --------------------------------------------------------------------------- +# §9.1 capability harness — stub source + hand-built frames +# --------------------------------------------------------------------------- + +VOCAB = ["h2020", "p2035", "p2035_low", "p2040", "p2040_low"] +# ``period_in_use`` for the capability tests: h2020 is the HISTORY +# anchor and is DELIBERATELY not in PIU (design §9.1). +PIU = ["p2035", "p2035_low", "p2040", "p2040_low"] + + +class _StubSource: + """Answers ``parameter(entity_class, name)`` probes from a dict; + ``KeyError`` for everything else (matches ``_try_param`` / + ``_solve_inflation_scalars`` fall-throughs).""" + + def __init__(self, params: dict[tuple[str, str], pl.DataFrame]): + self._params = params + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _years_from_start_df() -> pl.DataFrame: + # Hits arm ``_p_years_d_lf`` step 2 (``solve.years_from_start``) — + # deterministic, no cumulative-sum mechanics. h2020 → 0, the two + # p2035 siblings → 15, the two p2040 siblings → 20. + return pl.DataFrame({ + "name": ["s"] * 5, + "period": VOCAB, + "value": [0.0, 15.0, 15.0, 20.0, 20.0], + }) + + +def _years_represented_df() -> pl.DataFrame: + # Deliberately UNEQUAL sibling year counts so every wrong d_all + # subset is numerically distinguishable (design §9.1 W2). This + # intentionally violates NPV obligation (iii) — pure walker + # mechanics, no obligation check runs here. + return pl.DataFrame({ + "name": ["s"] * 4, + "period": ["p2035", "p2035_low", "p2040", "p2040_low"], + "value": [1.0, 1.0, 2.0, 3.0], + }) + + +def _stub_set() -> _StubSource: + return _StubSource({("solve", "years_from_start"): _years_from_start_df()}) + + +def _stub_factor() -> _StubSource: + return _StubSource({ + ("solve", "years_from_start"): _years_from_start_df(), + ("solve", "years_represented"): _years_represented_df(), + }) + + +def _ed_lf(d_dtype: pl.DataType | None = None) -> pl.LazyFrame: + """Anchor frame ``[(e1, h2020), (e1, p2035), (e1, p2035_low)]``.""" + ds = ["h2020", "p2035", "p2035_low"] + d_col = (pl.Series("d", ds, dtype=d_dtype) if d_dtype is not None + else pl.Series("d", ds)) + return pl.DataFrame({"e": ["e1", "e1", "e1"]}).with_columns( + d_col).lazy() + + +def _life_lf(d_dtype: pl.DataType | None = None) -> pl.LazyFrame: + ds = ["h2020", "p2035", "p2035_low"] + d_col = (pl.Series("d", ds, dtype=d_dtype) if d_dtype is not None + else pl.Series("d", ds)) + return pl.DataFrame({"e": ["e1", "e1", "e1"], + "life": [30.0, 30.0, 30.0]}).with_columns( + d_col).lazy() + + +# The Slice A §4.1 8-row ``dd_same_scenario`` table for the stoch +# fixture (leaf-pairs: same-leaf continuations + self rows). +LINEAGE_ROWS = [ + ("p2035", "p2035"), ("p2035", "p2040"), + ("p2035_low", "p2035_low"), ("p2035_low", "p2040_low"), + ("p2040", "p2035"), ("p2040", "p2040"), + ("p2040_low", "p2035_low"), ("p2040_low", "p2040_low"), +] + + +def _lineage(d_dtype: pl.DataType | None = None, + rows: list[tuple[str, str]] | None = None) -> pl.DataFrame: + rows = LINEAGE_ROWS if rows is None else rows + d = [r[0] for r in rows] + do = [r[1] for r in rows] + if d_dtype is not None: + return pl.DataFrame({ + "d": pl.Series("d", d, dtype=d_dtype), + "d_other": pl.Series("d_other", do, dtype=d_dtype), + }) + return pl.DataFrame({"d": d, "d_other": do}) + + +def _walk_set(source, lineage=None): + return (period_walk_iterator( + source, "s", _ed_lf(), PIU, VOCAB, + window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, + life_lf=_life_lf(), factor_side=None, lineage=lineage) + .collect().sort("e", "d", "d_all")) + + +def _walk_factor(source, lineage=None): + return (period_walk_iterator( + source, "s", _ed_lf(), PIU, VOCAB, + window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, + life_lf=_life_lf(), factor_side="inv", lineage=lineage) + .collect().sort("e", "d")) + + +def _rows(df: pl.DataFrame, cols) -> list[tuple]: + return [tuple(r) for r in df.select(cols).rows()] + + +# --- W1 — set-shape capability (the REQUIRED test) --------------------- + +W1_EXPECTED = [ + ("e1", "h2020", "p2035"), # history anchor — passes uncond. + ("e1", "h2020", "p2035_low"), # history anchor — passes uncond. + ("e1", "h2020", "p2040"), # history anchor — passes uncond. + ("e1", "h2020", "p2040_low"), # history anchor — passes uncond. + ("e1", "p2035", "p2035"), # in lineage (self) + ("e1", "p2035", "p2040"), # in lineage (same leaf) + ("e1", "p2035_low", "p2035_low"), # in lineage (self) + ("e1", "p2035_low", "p2040_low"), # in lineage (same leaf) +] + +# The four both-ends-PIU cross-leaf pairs the filter must REMOVE. +W1_ABSENT = [ + ("e1", "p2035", "p2035_low"), + ("e1", "p2035", "p2040_low"), + ("e1", "p2035_low", "p2035"), + ("e1", "p2035_low", "p2040"), +] + + +def test_w1_set_shape_capability() -> None: + """W1 (§9.1): ``factor_side=None`` with the 8-row lineage → exactly + the 8 rows. Contains BOTH mandated demonstrations: a + history-anchored row passing through unconditionally AND + both-ends-PIU cross-leaf pairs being removed.""" + out = _walk_set(_stub_set(), lineage=_lineage()) + got = _rows(out, ["e", "d", "d_all"]) + assert got == W1_EXPECTED + for row in W1_ABSENT: + assert row not in got, f"cross-leaf pair {row} should be removed" + + +# --- W2 — factor-side capability (proves PRE-aggregation) -------------- + +W2_EXPECTED = [ + ("e1", "h2020", 7.0), # 1 + 1 + 2 + 3 (unfiltered — history) + ("e1", "p2035", 3.0), # 1 (p2035) + 2 (p2040) + ("e1", "p2035_low", 4.0), # 1 (p2035_low) + 3 (p2040_low) +] + + +def test_w2_factor_pre_aggregation() -> None: + """W2 (§9.1): ``factor_side="inv"`` with the 8-row lineage → factor + sums 7.0 / 3.0 / 4.0. A post-aggregation filter cannot produce + 3.0/4.0 from the unfiltered 7.0 — this is the numerical proof that + the injection is PRE-aggregation.""" + out = _walk_factor(_stub_factor(), lineage=_lineage()) + got = _rows(out, ["e", "d", "factor"]) + assert got == W2_EXPECTED + + +def test_w2_factor_none_control() -> None: + """W2 control: ``lineage=None`` → every anchor sums the full 7.0.""" + out = _walk_factor(_stub_factor(), lineage=None) + got = _rows(out, ["e", "d", "factor"]) + assert got == [ + ("e1", "h2020", 7.0), + ("e1", "p2035", 7.0), + ("e1", "p2035_low", 7.0), + ] + + +# --- W3 — lineage=None unfiltered pin ---------------------------------- + +W3_EXPECTED = [ + ("e1", "h2020", "p2035"), + ("e1", "h2020", "p2035_low"), + ("e1", "h2020", "p2040"), + ("e1", "h2020", "p2040_low"), + ("e1", "p2035", "p2035"), + ("e1", "p2035", "p2035_low"), + ("e1", "p2035", "p2040"), + ("e1", "p2035", "p2040_low"), + ("e1", "p2035_low", "p2035"), + ("e1", "p2035_low", "p2035_low"), + ("e1", "p2035_low", "p2040"), + ("e1", "p2035_low", "p2040_low"), +] + + +def test_w3_none_unfiltered() -> None: + """W3 (§9.1): W1's inputs with ``lineage=None`` → the full 12-row + table (fine-grained walker-semantics pin, golden-independent).""" + out = _walk_set(_stub_set(), lineage=None) + assert _rows(out, ["e", "d", "d_all"]) == W3_EXPECTED + + +# --- W4 — empty-frame contract edge (set shape) ------------------------ + + +def test_w4_empty_frame_set() -> None: + """W4 (§9.1): a 0-row typed lineage frame → exactly the 4 h2020 + rows (every both-ends-PIU pair removed; history passes).""" + empty = pl.DataFrame(schema={"d": pl.Utf8, "d_other": pl.Utf8}) + out = _walk_set(_stub_set(), lineage=empty) + assert _rows(out, ["e", "d", "d_all"]) == [ + ("e1", "h2020", "p2035"), + ("e1", "h2020", "p2035_low"), + ("e1", "h2020", "p2040"), + ("e1", "h2020", "p2040_low"), + ] + + +# --- W4b — empty-frame contract edge (factor shape) -------------------- + + +def test_w4b_empty_frame_factor() -> None: + """W4b (§9.1 F9): a 0-row lineage frame, ``factor_side="inv"`` → + exactly ONE output row ``(e1, h2020, 7.0)``. The p2035 / p2035_low + anchors are ABSENT from the aggregate (all their pairs removed + before ``group_by``, so no group exists) — NOT present with factor + 0.0. The NPV consumers re-join with ``fill_null(0.0)``, so an + absent anchor becomes a zero factor downstream.""" + empty = pl.DataFrame(schema={"d": pl.Utf8, "d_other": pl.Utf8}) + out = _walk_factor(_stub_factor(), lineage=empty) + assert _rows(out, ["e", "d", "factor"]) == [("e1", "h2020", 7.0)] + + +# --- W5 — guard cases -------------------------------------------------- + + +def test_w5a_duplicate_pair_set() -> None: + """W5a (§9.1): a lineage with a DUPLICATED pair → output identical + to W1 (the mandatory ``.unique()`` guard prevents join fan-out).""" + dup = _lineage(rows=LINEAGE_ROWS + [("p2035", "p2035")]) + out = _walk_set(_stub_set(), lineage=dup) + assert _rows(out, ["e", "d", "d_all"]) == W1_EXPECTED + + +def test_w5a_duplicate_pair_factor() -> None: + """W5a factor leg: a duplicated pair must not double-count the + factor sums (the lift-dict-not-raw-CSV lesson).""" + dup = _lineage(rows=LINEAGE_ROWS + [("p2035", "p2040")]) + out = _walk_factor(_stub_factor(), lineage=dup) + assert _rows(out, ["e", "d", "factor"]) == W2_EXPECTED + + +def test_w5b_uncastable_token_raises() -> None: + """W5b (§9.1): a lineage token NOT castable into the Enum-typed + ``ed_lf.d`` vocabulary → ``ValueError`` naming the token (run with + a hand-Enum-typed anchor so the non-strict cast is non-trivial; + a Utf8 walk dtype could never null).""" + enum_d = pl.Enum(VOCAB) + bad = pl.DataFrame({ + "d": pl.Series("d", ["p2035", "p9999"], dtype=pl.Utf8), + "d_other": pl.Series("d_other", ["p2035", "p2040"], dtype=pl.Utf8), + }) + with pytest.raises(ValueError, match="p9999"): + period_walk_iterator( + _stub_set(), "s", _ed_lf(enum_d), PIU, VOCAB, + window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, + life_lf=_life_lf(enum_d), factor_side=None, + lineage=bad).collect() + + +def test_w5c_enum_x_enum_missing_token_raises() -> None: + """W5c (§9.1 F6): lineage columns typed as a DIFFERENT Enum than + the walk's Enum-typed ``d``, containing one token absent from the + walk vocabulary → ``ValueError`` from ``_assert_lineage_castable`` + (raise, never a silent null-drop). + + polars 1.40.1 empirical behavior this guards: a NON-STRICT cast + between two different Enum vocabularies NULLS every missing token + instead of raising — so it is the guard that raises, not polars.""" + enum_d = pl.Enum(VOCAB) + other_enum = pl.Enum(["p2035", "p2035_low", "p2040", "p2040_low", "zzz"]) + bad = pl.DataFrame({ + "d": pl.Series("d", ["p2035", "zzz"], dtype=other_enum), + "d_other": pl.Series("d_other", ["p2035", "p2040"], dtype=other_enum), + }) + with pytest.raises(ValueError, match="zzz"): + period_walk_iterator( + _stub_set(), "s", _ed_lf(enum_d), PIU, VOCAB, + window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, + life_lf=_life_lf(enum_d), factor_side=None, + lineage=bad).collect() + + +def test_w5c_enum_x_enum_all_present_identical() -> None: + """W5c (§9.1 F6): Enum×Enum with all tokens present in both + vocabularies → the non-strict re-cast is value-preserving, output + identical to W1.""" + enum_d = pl.Enum(VOCAB) + lin_enum = pl.Enum(["p2035", "p2035_low", "p2040", "p2040_low"]) + out = (period_walk_iterator( + _stub_set(), "s", _ed_lf(enum_d), PIU, VOCAB, + window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, + life_lf=_life_lf(enum_d), factor_side=None, + lineage=_lineage(lin_enum)) + .collect().sort("e", "d", "d_all")) + # walk-side ``d`` is Enum-typed, so compare against str values. + got = [(str(e), str(d), str(da)) for e, d, da in + out.select(["e", "d", "d_all"]).rows()] + assert got == W1_EXPECTED + + +# --------------------------------------------------------------------------- +# §9.2 / §9.3 stochastic fixture pins — solver-required fixture, +# solver-free assertions. +# --------------------------------------------------------------------------- + +WIND = "wind" +STOCH_SOLVE = "stoch_2p" + + +@pytest.fixture(scope="module") +def stoch_wf(scenario_workdir): + """The ``stoch`` scenario workdir (cascade incl. HiGHS), built + under ``FLEXTOOL_AUTOSCALE_STRICT=1`` — cached per (scenario, + db_fixture) by the session-scoped factory.""" + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("stoch", db_fixture="stoch_two_period") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def det_wf(scenario_workdir): + """A deterministic workdir on the same fixture (no fan rows).""" + return scenario_workdir("det", db_fixture="stoch_two_period") + + +def _reader(wf: Path): + from flextool.engine_polars import SpineDbReader + return SpineDbReader(wf / "tests.sqlite", "stoch") + + +def _det_reader(wf: Path): + from flextool.engine_polars import SpineDbReader + return SpineDbReader(wf / "tests.sqlite", "det") + + +def _provider(wf: Path) -> FlexDataProvider: + provider = FlexDataProvider() + if (wf / "input").exists(): + seed_provider_from_dir(provider, wf / "input", "input") + if (wf / "solve_data").exists(): + seed_provider_from_dir(provider, wf / "solve_data", "solve_data") + return provider + + +def _period_lists(wf: Path, reader, provider): + active = _read_active_solve(wf, provider=provider) + piu = _period_in_use_set(reader, active, wf, provider=provider) + pwh = _read_period_with_history(wf, provider=provider) or list(piu) + universe = _periodAll_from_source(reader, active, workdir=wf, + provider=provider) + return active, piu, pwh, universe + + +# --- W6 — the (e, p_k, p_j_b) structural pin AND §6.4 today-bug witness - + + +def test_w6_edd_invest_set_today_bug_witness(stoch_wf) -> None: + """W6 (§9.2): call ``edd_invest_set_lf`` with hand ed_invest + anchors ``[(wind, p2035), (wind, p2040)]`` on the real stoch + cascade inputs. wind's ``lifetime_method`` defaults to + ``reinvest_automatic`` → UNBOUNDED_FORWARD; walker years via the + §6.4 source fallback (``{p2035: 0.0, p2040: 1.0}``, synthetic + members → 0.0). + + Predicted 5 rows AND ``(wind, p2040, p2040_low)`` ABSENT — the + latter asserted as the TODAY-BUG WITNESS (erratum E1): under + correct (byte-copied) years that pair would be present, and + today's walker overwrite of the CSV seed drops it, so the absence + IS the live first-stage-visibility break for continuation anchors. + Slice D's year threading makes the pair appear — re-point the pin + then.""" + wf = stoch_wf + reader = _reader(wf) + provider = _provider(wf) + _, piu, pwh, _ = _period_lists(wf, reader, provider) + ed_invest_lf = pl.LazyFrame({ + "e": [WIND, WIND], "d": ["p2035", "p2040"]}) + out = (edd_invest_set_lf(reader, STOCH_SOLVE, ed_invest_lf, + pwh, piu, wf) + .collect()) + got = {(str(e), str(di), str(d)) for e, di, d in + out.select(["e", "d_invest", "d"]).rows()} + expected = { + (WIND, "p2035", "p2035"), + (WIND, "p2035", "p2035_low"), # the surviving year-0 channel + (WIND, "p2035", "p2040"), + (WIND, "p2035", "p2040_low"), # passes at fill-year 0 (§6.4) + (WIND, "p2040", "p2040"), + } + assert got == expected + # Erratum E1 today-bug witness: under correct byte-copied years + # this pair WOULD be present; today's walker overwrite drops it. + # Slice D's year-threading prerequisite resolves it — re-point here. + assert (WIND, "p2040", "p2040_low") not in got + + +# --- W7 — non-vacuity + repeat-determinism pins ------------------------ + + +def test_w7_edd_families_nonvacuous_deterministic(stoch_wf) -> None: + """W7 (§9.2): the edd/divest legs are non-vacuous on the stoch + workdir and repeat-deterministic (the None-path guarantees are + structural — default arg + untouched code path — witnessed by + W3/W6; this pins non-vacuity + determinism, not parity).""" + wf = stoch_wf + reader = _reader(wf) + provider = _provider(wf) + active, piu, pwh, _ = _period_lists(wf, reader, provider) + + # Walker join row order is not relied upon by any consumer + # (design §4.2), so sort before ``.equals`` — the pin is + # content-determinism, not row-stream identity. + def _sorted(df: pl.DataFrame) -> pl.DataFrame: + return df.sort(df.columns) + + hist1 = edd_history_lf(reader, STOCH_SOLVE, pwh, piu, wf).collect() + hist2 = edd_history_lf(reader, STOCH_SOLVE, pwh, piu, wf).collect() + assert hist1.height > 0 + assert _sorted(hist1).equals(_sorted(hist2)) + + ed_invest_lf = pl.LazyFrame({ + "e": [WIND, WIND], "d": ["p2035", "p2040"]}) + inv1 = edd_invest_set_lf(reader, STOCH_SOLVE, ed_invest_lf, + pwh, piu, wf).collect() + inv2 = edd_invest_set_lf(reader, STOCH_SOLVE, ed_invest_lf, + pwh, piu, wf).collect() + assert inv1.height > 0 + assert _sorted(inv1).equals(_sorted(inv2)) + + lb1 = edd_invest_lookback_set_lf( + reader, STOCH_SOLVE, ed_invest_lf, piu, wf).collect() + lb2 = edd_invest_lookback_set_lf( + reader, STOCH_SOLVE, ed_invest_lf, piu, wf).collect() + assert _sorted(lb1).equals(_sorted(lb2)) # may be empty, but stable + + # #18 divest pair-former with a hand pd_divest frame. + pd_divest = pl.DataFrame({"p": [WIND, WIND], "d": ["p2035", "p2040"]}) + dv1 = edd_divest_active_from_source(reader, STOCH_SOLVE, pd_divest) + dv2 = edd_divest_active_from_source(reader, STOCH_SOLVE, pd_divest) + assert dv1.height > 0 + assert _sorted(dv1).equals(_sorted(dv2)) + + +class _InvestMethodOverlay: + """Overlay source: delegates ``entities`` / ``parameter`` / + everything else to the wrapped reader, but injects + ``("unit", "invest_method")`` rows so an otherwise-vacuous invest + grid becomes non-empty (design §9.2 W7 F5).""" + + def __init__(self, reader, rows: pl.DataFrame): + self._reader = reader + self._rows = rows + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + if (entity_class, name) == ("unit", "invest_method"): + return self._rows + return self._reader.parameter(entity_class, name) + + def __getattr__(self, attr): + return getattr(self._reader, attr) + + +def test_w7_npv_variants_vacuity_and_overlay(stoch_wf) -> None: + """W7 (§9.2): the three INVEST-METHOD-GATED NPV ``_lf`` variants are + VACUOUS against the raw fixture (no ``entity__invest_method`` rows → + empty ``_entity_invest_set`` anchor grids); asserted empty AS the + vacuity pin. ONE leg (``npv_invest_discounted_lf``) is made + non-vacuous via an invest-method overlay stub — asserted non-empty + and repeat-deterministic. + + DEVIATION from design §9.2 (recorded, design §implementation-notes): + the design claimed all FOUR NPV legs are vacuous, but + ``lifetime_fixed_cost_invest_lf`` is anchored on ``all_entities × + period_with_history`` (NOT ``_entity_invest_set``), so it is + non-vacuous whenever the fixture has entities. It is pinned here + as non-empty + repeat-deterministic instead — a strictly stronger + non-vacuity witness that also exercises the lineage-threaded ops + walk on live data. The None-path guarantee is unaffected.""" + wf = stoch_wf + reader = _reader(wf) + provider = _provider(wf) + active, piu, pwh, universe = _period_lists(wf, reader, provider) + period_invest = ["p2035"] + + def _sorted(df: pl.DataFrame) -> pl.DataFrame: + return df.sort(df.columns) + + # The three invest/divest-method-gated legs — vacuity pin. + assert npv.npv_invest_discounted_lf( + reader, STOCH_SOLVE, period_invest, piu, universe + ).collect().height == 0 + assert npv.npv_divest_discounted_lf( + reader, STOCH_SOLVE, period_invest, piu, universe + ).collect().height == 0 + assert npv.lifetime_fixed_cost_divest_lf( + reader, STOCH_SOLVE, period_invest, piu, universe + ).collect().height == 0 + + # lifetime_fixed_cost_invest — all-entities anchored → non-vacuous; + # pin non-empty + repeat-deterministic (see DEVIATION above). + lfc1 = npv.lifetime_fixed_cost_invest_lf( + reader, STOCH_SOLVE, pwh, piu, universe).collect() + lfc2 = npv.lifetime_fixed_cost_invest_lf( + reader, STOCH_SOLVE, pwh, piu, universe).collect() + assert lfc1.height > 0 + assert _sorted(lfc1).equals(_sorted(lfc2)) + + # Overlay makes the invest leg non-vacuous (wind → invest_period). + overlay_rows = pl.DataFrame({"name": [WIND], "value": ["invest_period"]}) + overlay = _InvestMethodOverlay(reader, overlay_rows) + ov1 = npv.npv_invest_discounted_lf( + overlay, STOCH_SOLVE, period_invest, piu, universe).collect() + ov2 = npv.npv_invest_discounted_lf( + overlay, STOCH_SOLVE, period_invest, piu, universe).collect() + assert ov1.height > 0 # rows exist (value 0.0 without invest_cost) + assert _sorted(ov1).equals(_sorted(ov2)) + + +# --- P1 / P2 — obligation checker on live workdirs --------------------- + + +def test_p1_preconditions_clean_stoch(stoch_wf) -> None: + """P1 (§9.3): ``check_recourse_npv_preconditions`` → ``[]`` on the + stoch workdir (weights 0.25/0.75 constant per chain; sum-to-1 per + cohort; CSV year rows byte-equal).""" + wf = stoch_wf + reader = _reader(wf) + provider = _provider(wf) + active = _read_active_solve(wf, provider=provider) + violations = check_recourse_npv_preconditions( + wf, reader, active, provider=provider) + assert violations == [] + + +def test_p2_preconditions_clean_deterministic(det_wf) -> None: + """P2 (§9.3): ``[]`` on a deterministic workdir (no fan rows → + vacuously clean).""" + wf = det_wf + reader = _det_reader(wf) + provider = _provider(wf) + active = _read_active_solve(wf, provider=provider) + violations = check_recourse_npv_preconditions( + wf, reader, active, provider=provider) + assert violations == [] + + +# --- P3 — hand-built obligation violations (fully solver-free) --------- + +_PB = [("p2035", "p2035"), ("p2035", "p2035_rlz"), ("p2035", "p2035_low"), + ("p2040", "p2040"), ("p2040", "p2040_rlz"), ("p2040", "p2040_low")] +_PIU = ["p2035", "p2035_low", "p2040", "p2040_low"] +_SBTB = [("p2035_low", "low"), ("p2040_low", "low"), + ("p2035", "rlz"), ("p2040", "rlz")] +_BW = [("p2035", 1.0), ("p2035_rlz", 1.0), ("p2035_low", 3.0), + ("p2040", 1.0), ("p2040_rlz", 1.0), ("p2040_low", 3.0)] +_FT = [("p2035", "t0001"), ("p2035_low", "t0001"), + ("p2040", "t0004"), ("p2040_low", "t0004")] +_PYD = [("p2035", 0.0), ("p2035_low", 0.0), ("p2040", 1.0), ("p2040_low", 1.0)] +_PYR = [("p2035", "0", 1.0), ("p2035_low", "0", 1.0), + ("p2040", "1", 1.0), ("p2040_low", "1", 1.0)] + + +def _mk_provider(pb=None, piu=None, sbtb=None, bw=None, ft=None, + pyd=None, pyr=None) -> FlexDataProvider: + """Slice A ``_mk_provider`` idiom, extended with the branch-weight + cascade inputs and the two year CSVs the obligation checker reads.""" + p = FlexDataProvider() + if pb is not None: + p.put("solve_data/period__branch", pl.DataFrame( + {"period": [r[0] for r in pb], "branch": [r[1] for r in pb]})) + if piu is not None: + p.put("solve_data/period_in_use_set", + pl.DataFrame({"period": list(piu)})) + if sbtb is not None: + p.put("solve_data/solve_branch__time_branch", pl.DataFrame( + {"period": [r[0] for r in sbtb], "branch": [r[1] for r in sbtb]})) + if bw is not None: + p.put("solve_data/solve_branch_weight", pl.DataFrame( + {"branch": [r[0] for r in bw], + "p_branch_weight_input": [r[1] for r in bw]})) + if ft is not None: + p.put("solve_data/first_timesteps", pl.DataFrame( + {"period": [r[0] for r in ft], "time": [r[1] for r in ft]})) + if pyd is not None: + p.put("solve_data/p_years_d", pl.DataFrame( + {"period": [r[0] for r in pyd], "value": [r[1] for r in pyd]})) + if pyr is not None: + p.put("solve_data/p_years_represented", pl.DataFrame( + {"period": [r[0] for r in pyr], + "years_from_solve": [r[1] for r in pyr], + "p_years_represented": [r[2] for r in pyr]})) + return p + + +def test_p3a_unequal_chain_weights(tmp_path) -> None: + """P3a (§9.3): unequal ``pd_branch_weight`` along a chain → message + (i).""" + sbtb = [("p2035", "low"), ("p2035_low", "low"), + ("p2040", "rlz"), ("p2040_low", "rlz")] + v = check_recourse_npv_preconditions( + tmp_path, provider=_mk_provider( + pb=_PB, piu=_PIU, sbtb=sbtb, bw=_BW, ft=_FT, + pyd=_PYD, pyr=_PYR)) + assert any("obligation (i)" in m for m in v), v + + +def test_p3b_cohort_sum_violation(tmp_path) -> None: + """P3b (§9.3): a cohort whose weights do not sum to 1 → message + (ii). Distinct first-timesteps make each branch its own + normalization group, so the cohort weights sum > 1.""" + ft = [("p2035", "t0001"), ("p2035_low", "t0002"), + ("p2040", "t0004"), ("p2040_low", "t0005")] + sbtb = [("p2035", "a"), ("p2035_low", "b"), + ("p2040", "c"), ("p2040_low", "d")] + v = check_recourse_npv_preconditions( + tmp_path, provider=_mk_provider( + pb=_PB, piu=_PIU, sbtb=sbtb, bw=_BW, ft=ft, + pyd=_PYD, pyr=_PYR)) + assert any("obligation (ii)" in m for m in v), v + + +def test_p3c_diverging_years_d(tmp_path) -> None: + """P3c (§9.3): a fan member with a diverging ``p_years_d`` value → + message (iii).""" + pyd_bad = [("p2035", 0.0), ("p2035_low", 99.0), + ("p2040", 1.0), ("p2040_low", 1.0)] + v = check_recourse_npv_preconditions( + tmp_path, provider=_mk_provider( + pb=_PB, piu=_PIU, sbtb=_SBTB, bw=_BW, ft=_FT, + pyd=pyd_bad, pyr=_PYR)) + assert any("obligation (iii)" in m for m in v), v + + +def test_p3d_missing_years_represented(tmp_path) -> None: + """P3d (§9.3): a fan member missing a ``p_years_represented`` row → + message (iii).""" + pyr_missing = [("p2035", "0", 1.0), + ("p2040", "1", 1.0), ("p2040_low", "1", 1.0)] + v = check_recourse_npv_preconditions( + tmp_path, provider=_mk_provider( + pb=_PB, piu=_PIU, sbtb=_SBTB, bw=_BW, ft=_FT, + pyd=_PYD, pyr=pyr_missing)) + assert any("obligation (iii)" in m for m in v), v + + +# --- P4 — diagnostic regression pin of §6.4 (erratum E1 witness) ------- + + +def test_p4_walker_year_source_blindness(stoch_wf) -> None: + """P4 (§9.3, keep-diagnostic-tests): on the stoch workdir, + ``_p_years_d_lf(reader, active, wf)`` (provider-LESS, exactly as + the walker calls it) yields NO rows for the synthetic branch + members ``p2035_low`` / ``p2040_low``, WHILE + ``solve_data/p_years_d.csv`` carries them byte-equal to their + anchors. Together with W6's absence assert this is the erratum E1 + witness pair — the concrete numeric mechanism behind plan §8.3.""" + wf = stoch_wf + reader = _reader(wf) + # Provider-less call — the workdir-CSV arm is provider-gated, so + # this is the SOURCE-fallback resolution the walker actually uses. + pyd = _p_years_d_lf(reader, STOCH_SOLVE, wf).collect() + walker_periods = {str(d) for d in pyd["d"].to_list()} + assert "p2035_low" not in walker_periods + assert "p2040_low" not in walker_periods + + # The emitted CSV carries the byte-copied fan-member year rows. + csv = pl.read_csv(wf / "solve_data" / "p_years_d.csv", + infer_schema_length=0) + val_col = "value" if "value" in csv.columns else csv.columns[-1] + years = {str(p): float(v) for p, v in + zip(csv["period"].to_list(), csv[val_col].to_list())} + assert "p2035_low" in years and "p2040_low" in years + assert years["p2035_low"] == years["p2035"] + assert years["p2040_low"] == years["p2040"] From 91db38b4a96ec9c6d6179e295a0580550d951828 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 21:22:18 +0300 Subject: [PATCH 04/87] fix(stochastic): keep obligation checker I/O out of the cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slice B NPV obligation checker's year-row reader (`_read_solve_data_csv`) fell back to a bare `pl.read_csv(...)` on the workdir CSV when the Provider didn't carry the frame. That disk arm tripped the post-Step-2 cascade invariant (test_meta_provider_invariants::test_no_disk_csv_reads_in_cascade, Rule 1) — the seed-funnel bug class the invariant guards. Make the reader Provider-only (`_provider_get` -> `provider.get`), matching every other reader in `_derived_branch.py`. The checker now does no disk I/O; the caller owns placing the true emitted year CSVs on the Provider (tests seed via seed_provider_from_dir / _mk_provider; the Slice D activation site inherits the same solve_data frames the rest of the cascade already consumes). Obligation (iii) semantics preserved. SOUNDNESS SCOPE docstring + design (local) sec 6.2 updated with the deviation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 36 +++++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index f1005a6a1..a1cd6b784 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1061,9 +1061,21 @@ def pd_non_anticipativity_df( def _read_solve_data_csv(workdir: Path | None, provider: "object | None", name: str) -> pl.DataFrame | None: - """Provider-first / workdir-CSV-fallback read of - ``solve_data/`` (the Slice A T12 harness acquisition idiom). - Returns ``None`` when neither source carries the file. + """Provider-only fetch of ``solve_data/`` — the canonical + cascade pathway (matches every other reader in this module). + Returns ``None`` when the Provider is missing or does not carry the + frame. + + The obligation checker verifies the *emitted* solve_data year rows + (design §6.2 obligation (iii)); post-Step-2 those bytes reach the + cascade through the Provider, so this reader routes through + ``provider.get`` and never touches disk. The caller owns ensuring + the Provider carries the true emitted CSVs — in tests the fixtures + seed the Provider from the workdir (``seed_provider_from_dir`` / + ``_mk_provider``); at the Slice D activation site the same + solve_data frames the rest of the cascade already consumes are in + the Provider by construction. ``workdir`` is retained in the + signature only to key the canonical Provider path. """ if workdir is None: return None @@ -1071,13 +1083,6 @@ def _read_solve_data_csv(workdir: Path | None, df = _provider_get(provider, p) if df is not None and df.height > 0: return df - if p.exists(): - try: - df = pl.read_csv(p, infer_schema_length=0) - except Exception: - return None - if df.height > 0: - return df return None @@ -1161,9 +1166,14 @@ def check_recourse_npv_preconditions( tests only. SOUNDNESS SCOPE (Slice B design F4/§6.2): obligation (iii) is - checked against the emitted solve_data CSVs (``p_years_d`` / - ``period_with_history`` / ``p_years_represented``). The check - underwrites the lineage-filter arithmetic IF AND ONLY IF the + checked against the emitted solve_data year frames (``p_years_d`` / + ``period_with_history`` / ``p_years_represented``). This checker + performs NO disk I/O — it reads those frames through the Provider + (``provider.get``), so the CALLER owns making the true emitted CSVs + available on the Provider before invoking it (tests seed the + Provider from the workdir; the Slice D activation site inherits the + same solve_data frames the rest of the cascade already consumes). + The check underwrites the lineage-filter arithmetic IF AND ONLY IF the walker's year/factor source is those same CSVs (the Slice D year-threading prerequisite, Slice B design §6.4). If Slice D instead anchor-maps branch-period years/factors inside the From d15e31eeee459b8428664c7be4cc980ad3f3e261 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 21:29:54 +0300 Subject: [PATCH 05/87] feat(stochastic): schema opt-in stochastic_invest_method (v70) Add the opt-in solve.stochastic_invest_method enum (value list stochastic_invest_methods: none/recourse, default none) via migration v70. 'none' is byte-identical to prior behaviour; 'recourse' is reserved for Slice D and hard-rejected by the Slice C guards. - FLEXTOOL_DB_VERSION 69 -> 70; _migrate_v70_stochastic_invest_method mirrors the v64 solve.scaling exemplar (value list + definition + guarded solve_advanced grouping). - PARAMETER_TYPES row ('stochastic_invest_method','solve') DIMENSIONLESS. - _solve_config read/store + stochastic_invest_method_for resolver (absent -> 'none'). - Regenerated spinedb_schema.json master template + the two managed canonical DBs (templates_examples, howto_stochastics). - export_settings.yaml split_params: surface the new solve knob. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_solve_config.py | 37 +++++++++++ .../autoscale/_quantity_types.py | 2 + .../export_to_tabular/export_settings.yaml | 3 +- .../howto_stochastics.json | 27 +++++++- .../templates_examples.json | 27 +++++++- flextool/schemas/spinedb_schema.json | 18 +++++- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 61 +++++++++++++++++++ 8 files changed, 172 insertions(+), 5 deletions(-) diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index b435e7a56..9769cf4cb 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -247,6 +247,7 @@ def __init__( benders_tolerance: dict | None = None, benders_in_out_weight: dict | None = None, scaling: dict | None = None, + stochastic_invest_method: dict | None = None, ) -> None: # Base fields (read directly from DB in load_from_db). self.model = model @@ -310,6 +311,14 @@ def __init__( scaling if scaling is not None else {} ) + # v70 per-solve recourse-invest opt-in (solve-name -> "none"/ + # "recourse"); only authored solves appear. Resolved at access + # time via :meth:`stochastic_invest_method_for` (absent -> "none"). + self.stochastic_invest_method: dict = ( + stochastic_invest_method + if stochastic_invest_method is not None else {} + ) + # Computed fields — populated by load_from_db after construction. self.roll_counter: dict[str, int] = {} self.timesets_used_by_solves: defaultdict = defaultdict(list) @@ -523,6 +532,14 @@ def load_from_db( db=db, cl="solve", par="scaling", mode=DictMode.DICT ) + # v70 per-solve recourse-invest opt-in. Only solves that + # explicitly author solve.stochastic_invest_method appear; absent + # solves resolve to "none" via ``stochastic_invest_method_for``. + stochastic_invest_method: dict = params_to_dict( + db=db, cl="solve", par="stochastic_invest_method", + mode=DictMode.DICT, + ) + # rolling_times: assemble per-solve [jump, horizon, duration]. rolling_duration: dict = params_to_dict( db=db, cl="solve", par="rolling_duration", mode=DictMode.DICT @@ -630,6 +647,7 @@ def _opt_int(raw: dict, key: str) -> int | None: benders_tolerance=benders_tolerance, benders_in_out_weight=benders_in_out_weight, scaling=scaling, + stochastic_invest_method=stochastic_invest_method, ) # Computed fields — loading order MUST be preserved exactly. @@ -877,6 +895,25 @@ def scaling_for(self, solve_name: str) -> str | None: "off", "solver_only", "basic", "full" ) else None + def stochastic_invest_method_for(self, solve_name: str) -> str: + """Resolve ``solve.stochastic_invest_method`` for *solve_name*. + + Returns the normalised lower-case mode (``"none"`` or + ``"recourse"``). Absent / blank / unrecognised -> ``"none"`` + (the schema default, byte-identical prior behaviour). Authoring + case is ignored. + + Unlike :meth:`scaling_for` (which returns ``None`` for absent so + a CLI/env override can win) there is no run-time override for + this knob, so absent resolves to the concrete default ``"none"``. + Guard 1 (`_orchestration.run`) keys off this resolver. + """ + raw = self.stochastic_invest_method.get(solve_name) + if raw is None: + return "none" + value = str(raw).strip().lower() + return value if value in ("none", "recourse") else "none" + def benders_config_for(self, solve_name: str) -> tuple[int, float, float]: """Resolve ``(max_iter, tol, in_out_weight)`` for *solve_name*. diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 7ff2b7790..4864192ae 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -523,6 +523,8 @@ class QuantityType(Enum): # Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startu... ('stochastic_branches', 'solve'): QuantityType.DIMENSIONLESS, # [4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the... + ('stochastic_invest_method', 'solve'): QuantityType.DIMENSIONLESS, + # Opt-in mode string (none/recourse) for how stochastic branches participate in investment; a mode carries no physical unit. ('storage_binding_method', 'node'): QuantityType.DIMENSIONLESS, # Choice how the storage state will be maintained over discontinuos timelines. The default value 'bind_forward_only' will bind forward over an... ('storage_nested_fix_method', 'node'): QuantityType.DIMENSIONLESS, diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 9b403b751..47424fd11 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -84,7 +84,8 @@ split_params: "solver_mip_gap", "scaling", "timeline_hole_multiplier", "decomposition", "benders_tolerance", - "benders_max_iter", "benders_in_out_weight"] + "benders_max_iter", "benders_in_out_weight", + "stochastic_invest_method"] solve_period: params: ["period_timeset", "years_represented", "realized_periods", "invest_periods", "realized_invest_periods", "fix_storage_periods"] diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index 45ad8fc8a..f089a9b80 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -1321,7 +1321,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -2050,6 +2050,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5118,6 +5129,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index a30a5c2f5..6e660b5a9 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -2503,7 +2503,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -3221,6 +3221,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -6317,6 +6328,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index 4c64be42c..c58ac9919 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -709,6 +709,14 @@ "startup_methods", "binary" ], + [ + "stochastic_invest_methods", + "none" + ], + [ + "stochastic_invest_methods", + "recourse" + ], [ "storage_binding_methods", "bind_within_period" @@ -1603,7 +1611,7 @@ [ "model", "version", - 69.0, + 70.0, null, "Contains database version information.", "model" @@ -2256,6 +2264,14 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + "none", + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index aed3d9487..71a508833 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 69 +FLEXTOOL_DB_VERSION: int = 70 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 179fbc900..53c008228 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1761,6 +1761,8 @@ def _is_yes(pv): _migrate_v68_rp_group_flag(db) elif next_version == 69: _migrate_v69_backfill_parameter_groups(db) + elif next_version == 70: + _migrate_v70_stochastic_invest_method(db) else: print("Version invalid") last_completed_version = next_version @@ -3776,6 +3778,65 @@ def _migrate_v69_backfill_parameter_groups(db) -> None: ) +def _migrate_v70_stochastic_invest_method(db) -> None: + """Add the solve.stochastic_invest_method opt-in (v69 -> v70). + + Two-stage stochastic investment with recourse (recourse plan + §6b Slice D) is enabled per solve by this enum. 'none' (default, + byte-identical to prior behaviour) keeps the R-O6 invariant — + stochastic branches never enter the invest axis; v_invest stays + realized-only. 'recourse' selects the Slice D machinery. + + Slice C ships only the schema + the validation guards: flag-on is + HARD-REJECTED until Slice D lands (branch periods on the invest + axis). Bound to a new 'stochastic_invest_methods' value list; + grouped under 'solve_advanced' when that group exists. + """ + add_value_list_manual(db, [ + ["stochastic_invest_methods", "none"], + ["stochastic_invest_methods", "recourse"], + ]) + + default_val, default_type = to_database("none") + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="stochastic_invest_method", + default_value=default_val, + default_type=default_type, + parameter_value_list_name="stochastic_invest_methods", + description=( + "How stochastic branches participate in investment. 'none' " + "(default) keeps investment realized-only: stochastic " + "branches never enter the invest axis (v_invest stays on " + "realized periods), byte-identical to prior behaviour. " + "'recourse' enables two-stage stochastic investment with " + "recourse — shared (non-anticipative) investment before the " + "branch point plus per-branch recourse investment at and " + "after the branching period, with probability-weighted " + "investment costs. Reserved for future expected-value and " + "nested-tree modes." + ), + ) + has_solve_advanced = ( + db.item(db.mapped_table("parameter_group"), name="solve_advanced") + is not None + ) + if has_solve_advanced: + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="stochastic_invest_method", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v70: added solve.stochastic_invest_method " + "(stochastic_invest_methods: none/recourse, default none); " + "opt-in for recourse stochastic investment (Slice D)." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). From af6ebeeee6d8d73558a670acb0498e87728d3269 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 21:51:22 +0300 Subject: [PATCH 06/87] feat(stochastic): recourse guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three flag-independent / flag-keyed validation guards that keep the recourse opt-in inert until Slice D lands. - Guard 1 (flag hard-reject): stochastic_invest_method != none raises FlexToolConfigError at the top of _orchestration.run() (authoritative, covers rolling/contained solves via the roll-suffix strip) plus a fail-fast pass in _validate_model_solve over the top-level solves. - Guard 2 (Benders x genuine stochastics): _is_genuinely_stochastic raises in _run_benders_solve (primary) and at the top of solve_benders (defensive). Detector = period_branch_full d!=b b-tokens intersected with period_in_use_set, so the rolling realized-only bookkeeping shape does NOT false-positive. - Guard 3 (handoff synthetic-name assertion): build_handoff_from_solution rejects any realized_invest / committed period that is a synthetic branch fan member; None-degrades when flex_data is unavailable. New shared cycle-free module _stochastic_detect.py holds the single token-extraction idiom (synthetic_branch_tokens) used by Guards 2 and 3 and the PIU-intersected is_genuinely_stochastic used by Guard 2. Both cast d/b to Utf8 before comparing — at runtime period_branch_full is Enum-typed with disjoint d/b category sets, and comparing two enums raises in polars (would otherwise crash Guard 3 on every stochastic handoff); null b (deterministic shape) is dropped. Empirically verified on stoch_two_period: genuine stochastic -> True (the _rlz metadata members are excluded by the PIU intersection, the _low branches survive); deterministic + rolling -> False. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_benders.py | 16 ++++ flextool/engine_polars/_orchestration.py | 56 +++++++++++++ flextool/engine_polars/_stochastic_detect.py | 85 ++++++++++++++++++++ flextool/engine_polars/input.py | 45 +++++++++++ 4 files changed, 202 insertions(+) create mode 100644 flextool/engine_polars/_stochastic_detect.py diff --git a/flextool/engine_polars/_benders.py b/flextool/engine_polars/_benders.py index 61ec90742..37b72ccf5 100644 --- a/flextool/engine_polars/_benders.py +++ b/flextool/engine_polars/_benders.py @@ -114,6 +114,10 @@ unscale_solution, ) from flextool.engine_polars._region_filter import HalfFlow, RegionSplit +from flextool.engine_polars._solve_state import FlexToolConfigError +from flextool.engine_polars._stochastic_detect import ( + is_genuinely_stochastic as _is_genuinely_stochastic, +) from flextool.engine_polars.input import FlexData _logger = logging.getLogger(__name__) @@ -2043,6 +2047,18 @@ def solve_benders( ------- BendersResult """ + # Slice C Guard 2 (defensive): a caller reaching solve_benders while + # bypassing _run_benders_solve (the primary guard site) still fails + # loudly on a stochastic model. Same shared detector on the master + # ``data``. Benders x stochastics is not supported (recourse plan §4). + if _is_genuinely_stochastic(data): + raise FlexToolConfigError( + "solve_benders called on a stochastic model " + "(period__branch has non-realized branch periods with active " + "time); Benders x stochastics is not supported " + "(recourse plan §4)." + ) + if build_problem is None: def build_problem(pb, d): _build_flextool(pb, d, scale_the_objective=scale_the_objective) diff --git a/flextool/engine_polars/_orchestration.py b/flextool/engine_polars/_orchestration.py index 5dfe44ae0..d7e763643 100644 --- a/flextool/engine_polars/_orchestration.py +++ b/flextool/engine_polars/_orchestration.py @@ -60,6 +60,9 @@ from flextool.engine_polars._solve_acceptance import classify_acceptance from flextool.engine_polars._solve_handoff import SolveHandoff +from flextool.engine_polars._stochastic_detect import ( + is_genuinely_stochastic as _is_genuinely_stochastic, +) from flextool.engine_polars._solve_state import ( FlexToolConfigError, PathConfig, @@ -1560,6 +1563,17 @@ def _write_scale_csv( # --------------------------------------------------------------------------- +def _stochastic_invest_guard_message(solve_name: str, mode: str) -> str: + """Slice C Guard 1 message: recourse stochastic invest not yet available.""" + return ( + f"Solve '{solve_name}' sets stochastic_invest_method='{mode}', but " + f"recourse stochastic investment is not yet available; it requires " + f"the branch-period invest capability (branch periods on the invest " + f"axis — recourse plan Slice D). Set stochastic_invest_method=none " + f"(the default) to run this solve." + ) + + def _validate_model_solve(state: RunnerState) -> list[str]: """Validate ``state.solve.model_solve`` and return the solve list. @@ -1578,6 +1592,17 @@ def _validate_model_solve(state: RunnerState) -> list[str]: solves = next(iter(state.solve.model_solve.values())) if not solves: raise FlexToolConfigError("No solves in model.") + # Slice C Guard 1 (fail-fast pass): reject stochastic_invest_method + # != none for the model's top-level solves before any solve runs. + # This only sees the top-level ``solves`` array (not solves reached + # via ``contains_solves`` recursion), so it is a convenience — the + # authoritative per-solve guard lives at the top of ``run()``. + for _s in solves: + _mode = state.solve.stochastic_invest_method_for(_s) + if _mode != "none": + raise FlexToolConfigError( + _stochastic_invest_guard_message(_s, _mode) + ) return solves @@ -2093,6 +2118,25 @@ def _run_benders_solve( discover_decomposition_regions_from_db, ) + # Slice C Guard 2 (primary): Benders x genuine stochastics is + # not yet supported (the master's C_by_conn sum over all invest + # periods would double-count exclusive branch futures — recourse + # plan §4). Flag-independent: it closes the today-untested + # Benders x stochastics combination regardless of + # stochastic_invest_method. The detector reads two FlexData + # frames already in scope on ``data``. + if _is_genuinely_stochastic(data): + raise FlexToolConfigError( + f"Solve '{base_solve_name}' requests decomposition=benders " + f"on a stochastic model (period__branch has non-realized " + f"branch periods with active time). Benders x stochastics " + f"is not yet supported (the master's C_by_conn sum over " + f"all invest periods would double-count exclusive branch " + f"futures — recourse plan §4). Run this solve " + f"monolithically (decomposition=none) or remove the " + f"stochastic branches." + ) + # Regions are discovered from the DB (the same source the # group-level decomposition_method lives in). The DB-driven # run path (run_chain_from_db) always supplies ``db_url``; a @@ -2330,6 +2374,18 @@ def _on_subsolve(entry: dict) -> None: def run(self, complete_solve_name: str) -> int: _phase_prof("run_enter") + # Slice C Guard 1 (authoritative): recourse stochastic + # investment is opt-in but not yet implemented (Slice D). + # Reject flag-on for any solve that RUNS — including each roll + # of a rolling solve and each contained/chained solve — before + # building its FlexData/LP. The roll-suffix strip mirrors the + # base-name derivation below (``:2486``). + _guard_base = re.sub(r"_roll_\d+$", "", complete_solve_name) + _si_mode = state.solve.stochastic_invest_method_for(_guard_base) + if _si_mode != "none": + raise FlexToolConfigError( + _stochastic_invest_guard_message(_guard_base, _si_mode) + ) # Cross-level eviction — release any EXHAUSTED prior solve-level's # live HiGHS instance + flex_data_provider BEFORE this solve builds # its FlexData/LP, so two level footprints never coexist (the DES diff --git a/flextool/engine_polars/_stochastic_detect.py b/flextool/engine_polars/_stochastic_detect.py new file mode 100644 index 000000000..cca143bf6 --- /dev/null +++ b/flextool/engine_polars/_stochastic_detect.py @@ -0,0 +1,85 @@ +"""Shared stochastic-structure detectors for the recourse guards (Slice C). + +This module is deliberately dependency-light (polars only) so it can be +imported at module level by BOTH ``_orchestration`` and ``_benders`` +without reintroducing the ``_orchestration`` <-> ``_benders`` import +cycle (``_orchestration`` imports ``_benders`` locally to avoid it). + +Two guards read stochastic structure off ``period_branch_full``: + +* Guard 2 (Benders x genuine stochastics) needs the *genuinely + stochastic* synthetic branch periods only — the ``d != b`` branch + tokens that also have LP variables (are in ``period_in_use``). This + excludes metadata-only fan members (realized-named / zero-weight rows + that carry no active time). +* Guard 3 (handoff synthetic-name assertion) needs the *broader* bare + ``d != b`` b-token set: committing invest at a rolling metadata mirror + (e.g. ``period1_realized``) must ALSO be forbidden, and those rolling + metadata tokens never legitimately appear in ``realized_invest``. + +Both guards extract the ``d != b`` b-token set through the single shared +idiom :func:`synthetic_branch_tokens`; Guard 2 additionally intersects +it with ``period_in_use``. +""" + +from __future__ import annotations + +import polars as pl + + +def synthetic_branch_tokens(pbf) -> set[str]: + """Return the set of ``b`` tokens of ``period_branch_full`` ``d != b`` rows. + + ``pbf`` is a FlexData ``period_branch_full`` frame (cols ``d``, ``b``) + or ``None``. Returns an empty set when the frame is ``None`` or has + no ``d != b`` row. These are the synthetic stochastic-branch fan + members (the anchor self-rows ``(p_j, p_j)`` and continuation + self-rows are excluded by the ``d != b`` filter). + + Both columns are cast to ``Utf8`` BEFORE the comparison: at runtime + ``d`` and ``b`` are polars ``Enum`` columns with DIFFERENT category + sets (``b`` carries the synthetic-branch names ``d`` never does), and + comparing two enums with disjoint categories raises in polars. Null + ``b`` rows (the deterministic ``(p_j, null)`` shape) are dropped — + a null branch is not a synthetic token. + """ + if pbf is None: + return set() + return set( + pbf.select( + pl.col("d").cast(pl.Utf8).alias("d"), + pl.col("b").cast(pl.Utf8).alias("b"), + ) + .filter(pl.col("b").is_not_null() & (pl.col("d") != pl.col("b"))) + .get_column("b") + .to_list() + ) + + +def is_genuinely_stochastic(data) -> bool: + """True iff *data* has a synthetic branch period that owns LP variables. + + A solve is genuinely stochastic iff ``period_branch_full`` has a + ``d != b`` row whose ``b`` is ALSO in ``period_in_use_set``. The + intersection with ``period_in_use`` excludes metadata-only fan + members (realized-named / zero-weight rows in ``period__branch`` that + carry no active time), so the plain rolling-bookkeeping shape + (``(period1, period1_realized)``, a ``d != b`` metadata row) does + NOT trip this detector. + + Guard 2 is load-bearing only when ``period_in_use_set`` is populated + (the chain runner writes it). When it is ``None`` this degrades to + ``False`` — that is today's behaviour (Benders x stochastics is + unguarded today), not a regression; the flag-independent Guard 2 only + tightens over the populated-PIU case. + """ + piu = getattr(data, "period_in_use_set", None) + if piu is None: + return False + synth_b = synthetic_branch_tokens(getattr(data, "period_branch_full", None)) + if not synth_b: + return False + piu_set = set( + piu.select(pl.col("d").cast(pl.Utf8)).to_series().to_list() + ) + return bool(synth_b & piu_set) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 89f9ee8f3..2cebc383e 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5655,6 +5655,11 @@ def build_handoff_from_solution( import polars as pl # local — keep this helper's import surface narrow # Native import — Γ.8.D moved SolveHandoff into engine_polars. from flextool.engine_polars._solve_handoff import SolveHandoff + # Slice C Guard 3 — local imports keep this helper's import surface narrow. + from flextool.engine_polars._solve_state import FlexToolConfigError + from flextool.engine_polars._stochastic_detect import ( + synthetic_branch_tokens, + ) sd = work_folder / "solve_data" first_solve = _read_solve_first(work_folder, provider=provider) @@ -5753,6 +5758,46 @@ def build_handoff_from_solution( inv_rows.append((e, d, invested)) exist_rows.append((e, d, existing)) + # ---- Slice C Guard 3: no committed realized_invest period may be a + # synthetic stochastic-branch fan member (recourse plan §3.3). + # The token set is the BROAD d != b b-token set of + # period_branch_full (shared idiom ``synthetic_branch_tokens``) — + # deliberately broader than Guard 2's PIU-intersected set: + # committing invest at a metadata mirror (e.g. period1_realized, + # the rolling-bookkeeping token) must ALSO be forbidden, and those + # rolling metadata tokens never legitimately appear in + # realized_invest. None-degrade: when flex_data / + # period_branch_full is unavailable (the Benders TIER-1 snapshot + # call passes flex_data=None by design, :2278-2290) the check is a + # no-op — that path is a Benders invest solve, which Guard 2 + # already forbids for stochastic models, so no synthetic token can + # reach it. On today's (flag-off) code both branches are empty + # (realized branch keeps real names end-to-end), so the guard is + # inert and adds no golden change. + _pbf = getattr(flex_data, "period_branch_full", None) + _synth = synthetic_branch_tokens(_pbf) + if _synth: + # (a) config-level: realize_invest lists no synthetic token. + _bad_cfg = sorted(set(map(str, realize_invest)) & _synth) + if _bad_cfg: + raise FlexToolConfigError( + f"Solve '{solve_name}': realized_invest_periods lists " + f"synthetic stochastic-branch period(s) {_bad_cfg}. " + f"Only real (realized-branch) period names may be " + f"realized for investment." + ) + # (b) committed rows carry no synthetic period. + _bad_commit = sorted({ + d for (e, d, v) in inv_rows if v != 0.0 and d in _synth + }) + if _bad_commit: + raise FlexToolConfigError( + f"Solve '{solve_name}': handoff would commit investment " + f"at synthetic stochastic-branch period(s) {_bad_commit} " + f"— these belong to a non-realized scenario and must not " + f"enter the committed capacity chain (recourse plan §3.3)." + ) + # ---- divest_cumulative: prior + sum_d v_divest * unitsize ---- entity_divest = set(_read_singles_csv(sd / "entityDivest.csv", provider=provider)) From d934ef02a915d55d02ce1f32cfcfc5d29edf3561 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 22:02:44 +0300 Subject: [PATCH 07/87] test(stochastic): v70 migration + recourse guard tests - test_v70_stochastic_invest_method.py (M1-M3): migration round-trip (definition present, default none, bound to stochastic_invest_methods, grouped solve_advanced; value list {none,recourse}); pre-v70 -> v70 reach + idempotent re-run; master-template --verify up to date. - test_recourse_guards.py (G1, G2a-c, G3): resolver normalisation + _validate_model_solve reject/pass + rolling-suffix-strip composition (G1); PIU-intersected detector on genuine/deterministic/self-row/ rolling-realized-only shapes incl. the Enum disjoint-category regression pin and solve_benders defensive raise (G2); solver-based handoff clean-path / config-level synthetic raise / None-degrade (G3). Detector shapes mirror the empirically verified stoch_two_period runtime frames (Enum-typed d/b, PIU excludes _rlz metadata members). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- tests/engine_polars/test_recourse_guards.py | 263 ++++++++++++++++++ .../test_v70_stochastic_invest_method.py | 140 ++++++++++ 2 files changed, 403 insertions(+) create mode 100644 tests/engine_polars/test_recourse_guards.py create mode 100644 tests/update_flextool/test_v70_stochastic_invest_method.py diff --git a/tests/engine_polars/test_recourse_guards.py b/tests/engine_polars/test_recourse_guards.py new file mode 100644 index 000000000..4fb64d77e --- /dev/null +++ b/tests/engine_polars/test_recourse_guards.py @@ -0,0 +1,263 @@ +"""Slice C — recourse validation guards. + +Design: ``specs/sliceC_optin_guards_design.md`` §2/§4 (G1, G2a-c, G3). +The three guards keep the ``stochastic_invest_method`` opt-in inert until +Slice D: + +* Guard 1 — flag hard-reject (``stochastic_invest_method != none``) at the + top of ``run()`` + a fail-fast pass in ``_validate_model_solve``. +* Guard 2 — Benders x genuine stochastics, flag-independent + (``_is_genuinely_stochastic`` = ``period_branch_full`` d!=b tokens + intersected with ``period_in_use_set``). +* Guard 3 — handoff synthetic-name assertion in + ``build_handoff_from_solution``. + +G1/G2 are solver-free (hand-built frames / stub state). G3 solves one +small fixture once and drives the real handoff builder. +""" +from __future__ import annotations + +import dataclasses +import re +import shutil + +import polars as pl +import pytest + +from flextool.engine_polars._solve_config import SolveConfig +from flextool.engine_polars._solve_state import FlexToolConfigError +from flextool.engine_polars._stochastic_detect import ( + is_genuinely_stochastic, + synthetic_branch_tokens, +) + + +# --------------------------------------------------------------------------- +# Frame builders — mirror the runtime shapes verified empirically on +# ``stoch_two_period`` (period_branch_full is Enum-typed with DISJOINT +# d/b category sets; period_in_use_set is col ``d``). +# --------------------------------------------------------------------------- + + +def _pbf(pairs, *, enum=False) -> pl.DataFrame: + df = pl.DataFrame( + {"d": [p[0] for p in pairs], "b": [p[1] for p in pairs]} + ) + if enum: + d_cats = sorted({p[0] for p in pairs}) + b_cats = sorted({p[1] for p in pairs if p[1] is not None}) + df = df.with_columns( + pl.col("d").cast(pl.Enum(d_cats)), + pl.col("b").cast(pl.Enum(b_cats)), + ) + return df + + +def _piu(periods, *, enum=False) -> pl.DataFrame: + df = pl.DataFrame({"d": list(periods)}) + if enum: + df = df.with_columns(pl.col("d").cast(pl.Enum(sorted(set(periods))))) + return df + + +# =========================================================================== +# Guard 1 — flag hard-reject +# =========================================================================== + + +def _solve_config_with_flag(model_solve, flag_map) -> SolveConfig: + """A bare SolveConfig carrying only the two fields the guard reads — + ``model_solve`` and ``stochastic_invest_method`` (+ its resolver).""" + sc = object.__new__(SolveConfig) + sc.model_solve = model_solve + sc.stochastic_invest_method = flag_map + return sc + + +def test_g1_resolver_normalisation(): + sc = _solve_config_with_flag( + {"m": ["s"]}, + {"s": "recourse", "loud": "RECOURSE", "blank": "", "junk": "xyz"}, + ) + assert sc.stochastic_invest_method_for("s") == "recourse" + assert sc.stochastic_invest_method_for("loud") == "recourse" # case-fold + assert sc.stochastic_invest_method_for("blank") == "none" + assert sc.stochastic_invest_method_for("junk") == "none" + assert sc.stochastic_invest_method_for("absent") == "none" # default + + +def test_g1_validate_model_solve_rejects_flag_on(): + from flextool.engine_polars._orchestration import _validate_model_solve + + sc = _solve_config_with_flag({"m": ["s_ok", "s_bad"]}, + {"s_bad": "recourse"}) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + with pytest.raises(FlexToolConfigError) as exc: + _validate_model_solve(state) + msg = str(exc.value) + assert "s_bad" in msg + assert "branch-period invest capability" in msg + + +def test_g1_validate_model_solve_passes_flag_none(): + from flextool.engine_polars._orchestration import _validate_model_solve + + sc = _solve_config_with_flag({"m": ["s1", "s2"]}, {}) # all default none + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + assert _validate_model_solve(state) == ["s1", "s2"] + + +def test_g1_rolling_suffix_strip_composition(): + """The ``run()``-site guard strips the ``_roll_N`` suffix before the + resolver lookup, so a rolling child of a flag-on solve is rejected.""" + sc = _solve_config_with_flag({"m": ["my_solve"]}, + {"my_solve": "recourse"}) + base = re.sub(r"_roll_\d+$", "", "my_solve_roll_2") + assert base == "my_solve" + assert sc.stochastic_invest_method_for(base) == "recourse" + + +# =========================================================================== +# Guard 2 — Benders x genuine stochastics +# =========================================================================== + + +class _StubData: + def __init__(self, pbf, piu): + self.period_branch_full = pbf + self.period_in_use_set = piu + + +def test_g2a_genuine_stochastic_detected(): + """The stoch_two_period shape: (p2035,p2035_low) is a synthetic branch + with LP vars (p2035_low in PIU) — detector True.""" + pbf = _pbf([("p2035", "p2035"), ("p2035", "p2035_rlz"), + ("p2035", "p2035_low")], enum=True) + piu = _piu(["p2035", "p2035_low"], enum=True) + assert synthetic_branch_tokens(pbf) == {"p2035_rlz", "p2035_low"} + assert is_genuinely_stochastic(_StubData(pbf, piu)) is True + + +def test_g2a_solve_benders_raises_on_stochastic(): + """The defensive site at the top of ``solve_benders`` fires before any + region work.""" + from flextool.engine_polars._benders import solve_benders + + pbf = _pbf([("p2035", "p2035"), ("p2035", "p2035_low")], enum=True) + piu = _piu(["p2035", "p2035_low"], enum=True) + with pytest.raises(FlexToolConfigError) as exc: + solve_benders(_StubData(pbf, piu), ["region_a", "region_b"]) + assert "stochastic" in str(exc.value).lower() + + +def test_g2b_deterministic_not_detected(): + """Deterministic shape: b is NULL (never a synthetic token) — False, + and the Enum/null comparison does not crash.""" + pbf = _pbf([("p2035", None), ("p2040", None)], enum=True) + piu = _piu(["p2035", "p2040"], enum=True) + assert synthetic_branch_tokens(pbf) == set() + assert is_genuinely_stochastic(_StubData(pbf, piu)) is False + + +def test_g2b_self_rows_only_not_detected(): + pbf = _pbf([("p2035", "p2035"), ("p2040", "p2040")], enum=True) + piu = _piu(["p2035", "p2040"], enum=True) + assert synthetic_branch_tokens(pbf) == set() + assert is_genuinely_stochastic(_StubData(pbf, piu)) is False + + +def test_g2c_rolling_realized_only_bookkeeping_not_detected(): + """The false-positive pin (plan §5.1): the rolling metadata row + (period1, period1_realized) has d!=b but period1_realized is NOT in + period_in_use — the PIU intersection excludes it, so the detector + returns False. The BROAD token set still contains it (Guard 3's + concern), which the shared idiom exposes.""" + pbf = _pbf([("period1", "period1"), ("period1", "period1_realized")], + enum=True) + piu = _piu(["period1"], enum=True) # period1_realized absent (metadata) + # Broad set (Guard 3) includes the metadata token deliberately. + assert synthetic_branch_tokens(pbf) == {"period1_realized"} + # PIU-intersected detector (Guard 2) does NOT fire. + assert is_genuinely_stochastic(_StubData(pbf, piu)) is False + + +def test_g2_none_frames_degrade_false(): + assert is_genuinely_stochastic(_StubData(None, None)) is False + # PIU None -> degrade to today's behaviour (load-bearing only when PIU + # is populated). + pbf = _pbf([("p2035", "p2035_low")], enum=True) + assert is_genuinely_stochastic(_StubData(pbf, None)) is False + assert synthetic_branch_tokens(None) == set() + + +# =========================================================================== +# Guard 3 — handoff synthetic-name assertion (solver-based integration) +# =========================================================================== + +SCENARIO = "coal_ladder_cumulative" +SOLVE_NAME = "y2020_2day_dispatch" +REALIZE_CSV = "solve_data/realized_invest_periods_of_current_solve.csv" + + +@pytest.fixture(scope="module") +def _solved(scenario_workdir): + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + from flextool.engine_polars.input import build_handoff_from_solution + + work = scenario_workdir(SCENARIO) + data = load_flextool(work) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return sol, work, data, build_handoff_from_solution + + +@pytest.mark.solver +def test_g3_clean_real_names_no_raise(_solved): + """The happy path — a deterministic (real-named) flex_data commits no + synthetic period, so the guard is inert.""" + sol, work, data, build_handoff = _solved + handoff = build_handoff(sol, work, SOLVE_NAME, flex_data=data) + assert handoff is not None + + +@pytest.mark.solver +def test_g3_config_level_synthetic_in_realize_invest_raises( + _solved, tmp_path): + """realized_invest_periods listing a synthetic branch token → config + raise (case a).""" + sol, work, data, build_handoff = _solved + twork = tmp_path / "work_g3a" + shutil.copytree(work, twork) + csv = twork / REALIZE_CSV + csv.parent.mkdir(parents=True, exist_ok=True) + csv.write_text("period\np2020_low\n") + # flex_data whose period_branch_full marks p2020_low as a synthetic + # fan member of the real anchor p2020. + bad = dataclasses.replace( + data, + period_branch_full=_pbf([("p2020", "p2020"), + ("p2020", "p2020_low")], enum=True), + ) + with pytest.raises(FlexToolConfigError) as exc: + build_handoff(sol, twork, SOLVE_NAME, flex_data=bad) + assert "p2020_low" in str(exc.value) + + +@pytest.mark.solver +def test_g3_none_frame_degrades_no_raise(_solved, tmp_path): + """period_branch_full unavailable → the guard is a no-op even when the + realize CSV carries a synthetic token (the documented None-degrade; + Guard 2 covers that path's stochastic case).""" + sol, work, data, build_handoff = _solved + twork = tmp_path / "work_g3n" + shutil.copytree(work, twork) + csv = twork / REALIZE_CSV + csv.parent.mkdir(parents=True, exist_ok=True) + csv.write_text("period\np2020_low\n") + degraded = dataclasses.replace(data, period_branch_full=None) + # No raise — synthetic_branch_tokens(None) is empty, guard skipped. + handoff = build_handoff(sol, twork, SOLVE_NAME, flex_data=degraded) + assert handoff is not None diff --git a/tests/update_flextool/test_v70_stochastic_invest_method.py b/tests/update_flextool/test_v70_stochastic_invest_method.py new file mode 100644 index 000000000..75b4aad44 --- /dev/null +++ b/tests/update_flextool/test_v70_stochastic_invest_method.py @@ -0,0 +1,140 @@ +"""Tests for the v70 database migration (Slice C). + +v70 adds ``solve.stochastic_invest_method`` — the opt-in enum selecting +how stochastic branches participate in investment: + +1. Creates the value list ``stochastic_invest_methods`` == {none, recourse}. +2. Adds the ``solve.stochastic_invest_method`` definition (default + ``"none"``) bound to that value list, grouped under ``solve_advanced``. + +``'none'`` (default) is byte-identical to prior behaviour; ``'recourse'`` +is hard-rejected by the Slice C guards until Slice D lands. The +engine-side read/resolver + guards are covered by +``tests/engine_polars/test_recourse_guards.py``. +""" + +from __future__ import annotations + +from pathlib import Path + +from spinedb_api import DatabaseMapping, from_database + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + + +def test_v70_version_constant_is_at_least_70() -> None: + """The engine must report a schema version >= 70 — the + stochastic_invest_method lower bound. Later migrations keep raising + the constant, so an exact-equality assertion would regress. + """ + assert FLEXTOOL_DB_VERSION >= 70 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at the fixture's old version, which + predates v70 so the parameter can ONLY appear via the v70 block), + migrate it to HEAD, and return an open mapping.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- M1 / M3: the migration adds the definition + value list ------------ + + +def test_stochastic_invest_method_definition_present(tmp_path: Path) -> None: + """``solve.stochastic_invest_method`` exists, defaults to ``"none"``, + is bound to ``stochastic_invest_methods`` and grouped ``solve_advanced``.""" + db = _migrated_db(tmp_path) + try: + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="stochastic_invest_method", + ) + assert pdef, "stochastic_invest_method not added by migration" + default = from_database(pdef["default_value"], pdef["default_type"]) + assert default == "none" + assert pdef["parameter_value_list_name"] == "stochastic_invest_methods" + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_stochastic_invest_methods_value_list(tmp_path: Path) -> None: + """``stochastic_invest_methods`` == {none, recourse}.""" + db = _migrated_db(tmp_path) + try: + values = { + from_database(v["value"], v["type"]) + for v in db.get_list_value_items() + if v["parameter_value_list_name"] == "stochastic_invest_methods" + } + assert values == {"none", "recourse"} + finally: + db.close() + + +def test_migration_reaches_v70_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v70 fixture migrates to exactly v70 (>= 70), and re-running + ``migrate_database`` is a no-op that keeps the parameter present — + the ``add_value_list_manual`` / ``add_update_item`` helpers tolerate + re-adds.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + start_version = _db_version(url) + assert start_version < 70, ( + "fixture must start below v70 so the v70 block is exercised" + ) + + migrate_database(url) + assert _db_version(url) == FLEXTOOL_DB_VERSION >= 70 + + # Idempotent re-run — no crash, parameter still present. + migrate_database(url) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="stochastic_invest_method", + ) + assert pdef, "parameter lost on idempotent re-migration" + assert ( + from_database(pdef["default_value"], pdef["default_type"]) == "none" + ) + + +# --- M2: the committed master template is up to date -------------------- + + +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (with the v70 parameter) was committed. + This is the same assertion CI makes; pinning it locally catches a + forgotten regen.""" + assert sync_master_template(verify_only=True) is True From abc188c139cb634f27cc92e5d32ac66097c19f30 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 23:44:12 +0300 Subject: [PATCH 08/87] feat(stochastic): D0 recourse-active plumbing (inert) Emit solve_data/stochastic_invest_method.csv (resolved per-solve mode) next to the realized-invest emit; _recourse_invest_active reader (provider-first, workdir fallback, absent -> False); FlexData.recourse_invest bool stamped in apply_derived_c; _has_branch_invest_axis capability conjunct reusing is_genuinely_stochastic. Dump-registry + accumulator-manifest lines so a dump/reload round-trip preserves the flag. Flag defaults none -> no behavior change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_params.py | 52 ++++++++ flextool/engine_polars/_dump_csvs.py | 1 + flextool/engine_polars/_emit_solve_writers.py | 16 +++ .../engine_polars/_flex_data_accumulator.py | 1 + flextool/engine_polars/_native_run_model.py | 9 ++ flextool/engine_polars/_provider_keys.py | 1 + flextool/engine_polars/input.py | 7 ++ notes.md | 7 ++ tests/engine_polars/test_recourse_plumbing.py | 114 ++++++++++++++++++ 9 files changed, 208 insertions(+) create mode 100644 notes.md create mode 100644 tests/engine_polars/test_recourse_plumbing.py diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 92383f90d..1bf1351d6 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -113,6 +113,52 @@ def _provider_read(provider, path: "Path | str") -> "pl.DataFrame": ) return provider.get(_provider_key(path)) + +def _recourse_invest_active( + workdir: "Path | None", + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> bool: + """True iff ``solve_data/stochastic_invest_method.csv`` == ``'recourse'``. + + Slice D's single gate. False when the CSV is absent (deterministic / + flag-off / pre-Slice-D dumps) — the byte-parity-safe default. Reads + provider-first (canonical key), then a workdir-disk fallback so a + dumped/reloaded solve still resolves the flag. + """ + path = Path("solve_data") / "stochastic_invest_method.csv" + df: "pl.DataFrame | None" = None + if _provider_has_key(provider, path): + df = _provider_read(provider, path) + elif workdir is not None: + disk = Path(workdir) / "solve_data" / "stochastic_invest_method.csv" + if disk.exists(): + df = pl.read_csv(disk) + if df is None or df.height == 0 or "method" not in df.columns: + return False + val = df["method"].cast(pl.Utf8, strict=False).to_list()[0] + return str(val).strip().lower() == "recourse" if val is not None else False + + +def _has_branch_invest_axis(flex_data: "object | None") -> bool: + """Capability conjunct (Slice B §8, design §2): True iff the invest + axis actually carries synthetic branch members. + + Reuses :func:`_stochastic_detect.is_genuinely_stochastic` — a branch + period enters ``period_invest`` iff it is a synthetic ``period__branch`` + token that is also in ``period_in_use_set``. Keeps the lineage filter + provably inert on any solve whose invest axis carries no branch member + even if the flag was authored (defense-in-depth behind Guard 1). + """ + if flex_data is None: + return False + from flextool.engine_polars._stochastic_detect import ( + is_genuinely_stochastic as _igs, + ) + return _igs(flex_data) + + # Substrate handle for the cascade-wide axis enum vocabulary. # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live @@ -5306,6 +5352,12 @@ def apply_derived_c( dt_csv = getattr(flex_data, "dt", None) sd_csv = getattr(flex_data, "p_step_duration", None) + # Slice D — resolve the per-scenario stochastic-invest flag once and + # stamp it on FlexData (survives the region-filter dataclasses.replace) + # so the model layer reads ``d.recourse_invest`` without a workdir touch. + flex_data.recourse_invest = _recourse_invest_active( + workdir, ctx=ctx, provider=provider) + # Δ.12b — assignment is unconditional except for fields with # documented helper-coverage gaps (multi-year cascade extends # apply_derived_a's path; fall-throughs noted inline). diff --git a/flextool/engine_polars/_dump_csvs.py b/flextool/engine_polars/_dump_csvs.py index b8f50e4b3..3ded846a3 100644 --- a/flextool/engine_polars/_dump_csvs.py +++ b/flextool/engine_polars/_dump_csvs.py @@ -810,6 +810,7 @@ def _write_p_input_sliced(data: "FlexData", inp_dir: Path) -> None: ("solve_data", "p_nested_model.csv"), ("solve_data", "fix_storage_timesteps.csv"), ("solve_data", "realized_invest_periods_of_current_solve.csv"), + ("solve_data", "stochastic_invest_method.csv"), ("solve_data", "realized_dispatch.csv"), ("solve_data", "p_entity_pre_existing.csv"), ("solve_data", "entity.csv"), diff --git a/flextool/engine_polars/_emit_solve_writers.py b/flextool/engine_polars/_emit_solve_writers.py index 48184b7b2..7ed7086b7 100644 --- a/flextool/engine_polars/_emit_solve_writers.py +++ b/flextool/engine_polars/_emit_solve_writers.py @@ -321,6 +321,22 @@ def emit_periods( _emit_path(provider, filename, derive_periods(solve, periods_dict)) +def emit_stochastic_invest_method( + method: str, + filename: str, + *, provider, +) -> None: + """Emit the resolved ``solve.stochastic_invest_method`` (Slice D). + + A single-row, single-column (``method``) frame carrying the resolved + per-solve mode (``"none"`` / ``"recourse"``). Read run-time via + ``_derived_params._recourse_invest_active`` so the derived/model + layers can gate the recourse feature without a ``SolveConfig``. + """ + _emit_path(provider, filename, + _to_utf8_frame(("method",), [(method,)])) + + def _compute_first_and_last_periods( active_time_list: dict[str, list[tuple[str, ...]]], period__timesets_in_this_solve: list[tuple[str, str]], diff --git a/flextool/engine_polars/_flex_data_accumulator.py b/flextool/engine_polars/_flex_data_accumulator.py index c79d3870a..5e85fe5c2 100644 --- a/flextool/engine_polars/_flex_data_accumulator.py +++ b/flextool/engine_polars/_flex_data_accumulator.py @@ -56,6 +56,7 @@ "period_solve.csv", "time.csv", "enable_optional_outputs.csv", + "stochastic_invest_method.csv", "nodeState_rp.csv", "nodeStateBlock.csv", "timeline.csv", diff --git a/flextool/engine_polars/_native_run_model.py b/flextool/engine_polars/_native_run_model.py index c98fb217c..e140f3501 100644 --- a/flextool/engine_polars/_native_run_model.py +++ b/flextool/engine_polars/_native_run_model.py @@ -665,6 +665,15 @@ def _mem_cp(label: str, user_label: str, complete_solve[solve], state.solve.invest_periods, ), ) + # Slice D — emit the RESOLVED stochastic_invest_method so the + # derived/model layers can gate the recourse feature through the + # provider/workdir (they hold no SolveConfig). Absent CSV == + # flag-off (byte-parity-safe default). + solve_writers.emit_stochastic_invest_method( + state.solve.stochastic_invest_method_for(complete_solve[solve]), + str(wf / "solve_data/stochastic_invest_method.csv"), + provider=sub_solve_provider, + ) years_rep = state.solve.solve_period_years_represented[ complete_solve[solve] diff --git a/flextool/engine_polars/_provider_keys.py b/flextool/engine_polars/_provider_keys.py index 88e65c42d..38fed50bc 100644 --- a/flextool/engine_polars/_provider_keys.py +++ b/flextool/engine_polars/_provider_keys.py @@ -80,6 +80,7 @@ ) SOLVE_DATA_PROCESS_SOURCE_TOSINK = "solve_data/process_source_toSink" SOLVE_DATA_REGION_COUPLING = "solve_data/region_coupling" +SOLVE_DATA_STOCHASTIC_INVEST_METHOD = "solve_data/stochastic_invest_method" # --------------------------------------------------------------------------- diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 2cebc383e..5d77eaf23 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -939,6 +939,13 @@ class FlexData: dd_same_scenario: pl.DataFrame | None = None # (d, d_other) pd_non_anticipativity: pl.DataFrame | None = None # (d, b) + # Slice D — per-scenario (wait-and-see) stochastic investment gate. + # Set once in ``apply_derived_c`` from + # ``solve_data/stochastic_invest_method.csv`` == 'recourse'. Survives + # ``dataclasses.replace`` (region filter) like the lineage frames so + # ``model.py`` reads ``d.recourse_invest`` without a second CSV read. + recourse_invest: bool = False + # ─── Gap F final — handoff-path auxiliaries ─────────────────────────── # Per-solve in-memory carriers for fields that ``build_handoff_from_solution`` # would otherwise re-read from ``solve_data/`` to capture the post-solve diff --git a/notes.md b/notes.md new file mode 100644 index 000000000..c181c4432 --- /dev/null +++ b/notes.md @@ -0,0 +1,7 @@ +## FlexTool 4.0.2 — results-database schema seeding fix + +Patch release. No schema or model changes. + +- **The results database is no longer seeded from an outdated schema.** On install/update, `flextool-update` created `templates/results_template.sqlite` and `results.sqlite` from the legacy pre-v26 template (old `object_parameters` / `relationship_parameters` format) rather than the current results schema, which could break the results-import / Spine Toolbox workflow. It now uses `schemas/spinedb_results_schema.json` (modern `entity_classes` / `parameter_definitions`), consistent with the output-write path's `ensure_results_db`. (#329) + +**Full changelog:** see `CHANGELOG.md`. diff --git a/tests/engine_polars/test_recourse_plumbing.py b/tests/engine_polars/test_recourse_plumbing.py new file mode 100644 index 000000000..e554837c4 --- /dev/null +++ b/tests/engine_polars/test_recourse_plumbing.py @@ -0,0 +1,114 @@ +"""Slice D — Phase D0 recourse-active plumbing (inert). + +Design: ``specs/sliceD_recourse_invest_design.md`` §2 / §18 D0. +Covers the flag-emit → read round-trip and the two-part activation +predicate (flag CSV == 'recourse' AND branch invest axis present). All +solver-free: the CSV read and the capability conjunct are pure. +""" +from __future__ import annotations + +import polars as pl + +from flextool.engine_polars._derived_params import ( + _has_branch_invest_axis, + _recourse_invest_active, +) +from flextool.engine_polars._emit_solve_writers import ( + emit_stochastic_invest_method, +) + + +CSV = "solve_data/stochastic_invest_method.csv" + + +def _write(workdir, text: str) -> None: + p = workdir / "solve_data" + p.mkdir(parents=True, exist_ok=True) + (p / "stochastic_invest_method.csv").write_text(text) + + +# --------------------------------------------------------------------------- +# Reader — workdir-disk fallback + absent-default +# --------------------------------------------------------------------------- + + +def test_recourse_active_true(tmp_path): + _write(tmp_path, "method\nrecourse\n") + assert _recourse_invest_active(tmp_path) is True + + +def test_recourse_active_none_flag_false(tmp_path): + _write(tmp_path, "method\nnone\n") + assert _recourse_invest_active(tmp_path) is False + + +def test_recourse_active_absent_csv_false(tmp_path): + # No CSV emitted — the byte-parity-safe default. + assert _recourse_invest_active(tmp_path) is False + + +def test_recourse_active_workdir_none_false(): + assert _recourse_invest_active(None) is False + + +# --------------------------------------------------------------------------- +# Emit round-trip through a real Provider (mirrors the run-model emit) +# --------------------------------------------------------------------------- + + +def test_emit_read_roundtrip_provider(tmp_path): + from flextool.engine_polars._flex_data_provider import FlexDataProvider + + prov = FlexDataProvider() + emit_stochastic_invest_method( + "recourse", str(tmp_path / CSV), provider=prov) + assert _recourse_invest_active(tmp_path, provider=prov) is True + + prov2 = FlexDataProvider() + emit_stochastic_invest_method( + "none", str(tmp_path / CSV), provider=prov2) + assert _recourse_invest_active(tmp_path, provider=prov2) is False + + +# --------------------------------------------------------------------------- +# Capability conjunct — branch invest axis present +# --------------------------------------------------------------------------- + + +class _Data: + def __init__(self, pbf, piu): + self.period_branch_full = pbf + self.period_in_use_set = piu + + +def _pbf(pairs): + d_cats = sorted({p[0] for p in pairs}) + b_cats = sorted({p[1] for p in pairs if p[1] is not None}) + return pl.DataFrame( + {"d": [p[0] for p in pairs], "b": [p[1] for p in pairs]} + ).with_columns( + pl.col("d").cast(pl.Enum(d_cats)), + pl.col("b").cast(pl.Enum(b_cats)), + ) + + +def _piu(periods): + return pl.DataFrame({"d": list(periods)}).with_columns( + pl.col("d").cast(pl.Enum(sorted(set(periods)))) + ) + + +def test_has_branch_invest_axis_true(): + pbf = _pbf([("p2035", "p2035"), ("p2035", "p2035_low")]) + piu = _piu(["p2035", "p2035_low"]) + assert _has_branch_invest_axis(_Data(pbf, piu)) is True + + +def test_has_branch_invest_axis_deterministic_false(): + pbf = _pbf([("p2035", None), ("p2040", None)]) + piu = _piu(["p2035", "p2040"]) + assert _has_branch_invest_axis(_Data(pbf, piu)) is False + + +def test_has_branch_invest_axis_none_false(): + assert _has_branch_invest_axis(None) is False From 95d1411918bb035dd45ce2fe468a4ac74ecbc200 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 22 Sep 2026 23:47:24 +0300 Subject: [PATCH 09/87] =?UTF-8?q?feat(stochastic):=20D1=20beta=20narrowing?= =?UTF-8?q?=20=E2=80=94=20LineageFilterError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define LineageFilterError(ValueError) in _solve_state; the two Slice B lineage guards (_assert_lineage_castable, assert_recourse_npv_preconditions) raise it. Narrow the four blanket except-Exception swallows in apply_derived_c / apply_synthetic_invest_sets (edd lookback / edd_invest / edd_divest_active / synthetic edd) to re-raise LineageFilterError so a lineage failure is loud instead of silently reverting to unfiltered first-stage sets. Raise path is unreachable while lineage is None (flag-off byte-parity). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 3 +- flextool/engine_polars/_derived_params.py | 9 ++++ flextool/engine_polars/_derived_walks.py | 3 +- flextool/engine_polars/_solve_state.py | 16 ++++++ .../test_recourse_lineage_error.py | 49 +++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/engine_polars/test_recourse_lineage_error.py diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index a1cd6b784..269c02670 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1296,7 +1296,8 @@ def assert_recourse_npv_preconditions( workdir, source, active_solve, ctx=ctx, provider=provider, weight_tol=weight_tol) if violations: - raise ValueError( + from flextool.engine_polars._solve_state import LineageFilterError + raise LineageFilterError( "recourse NPV preconditions violated:\n " + "\n ".join(violations) ) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 1bf1351d6..96fd0a588 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -55,6 +55,7 @@ schema_dtype, ) from ._emit_provider_io import _provider_key +from ._solve_state import LineageFilterError from ._param_shapes import ( broadcast_to_period_time, promote_param_to_dt, @@ -5538,6 +5539,8 @@ def apply_derived_c( try: eil_db = edd_invest_lookback_set_from_source( source, active_solve, ed_inv_used, workdir) + except LineageFilterError: + raise except Exception: eil_db = None if eil_db is not None and eil_db.height > 0: @@ -5558,6 +5561,8 @@ def apply_derived_c( edd_inv_db = _edd_invest_lf( source, active_solve, ed_inv_used.lazy(), period_with_history, period_in_use, workdir).collect() + except LineageFilterError: + raise except Exception: edd_inv_db = None if edd_inv_db is not None and edd_inv_db.height > 0: @@ -5567,6 +5572,8 @@ def apply_derived_c( try: edda_db = edd_divest_active_from_source( source, active_solve, pd_div_used) + except LineageFilterError: + raise except Exception: edda_db = None if edda_db is not None and edda_db.height > 0: @@ -9910,6 +9917,8 @@ def apply_synthetic_invest_sets(flex_data: object, edd_inv = _edd_invest_lf( source, active_solve, ed_inv.lazy(), period_with_history, period_in_use, workdir).collect() + except LineageFilterError: + raise except Exception: # pragma: no cover — defensive edd_inv = None if edd_inv is not None and edd_inv.height > 0: diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index 72b63c07c..0595b272f 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -127,7 +127,8 @@ def _assert_lineage_castable(lineage: pl.DataFrame, bad.extend(str(v) for v, c in zip(raw.to_list(), cast.to_list()) if c is None) if bad: - raise ValueError( + from flextool.engine_polars._solve_state import LineageFilterError + raise LineageFilterError( "period_walk_iterator lineage filter: token(s) " f"{sorted(set(bad))} in the lineage frame are not castable " f"to the walk's d dtype {d_dtype!r} — a silently-nulled " diff --git a/flextool/engine_polars/_solve_state.py b/flextool/engine_polars/_solve_state.py index f5b8e6b2e..8a43a76d6 100644 --- a/flextool/engine_polars/_solve_state.py +++ b/flextool/engine_polars/_solve_state.py @@ -39,6 +39,22 @@ class FlexToolSolveError(FlexToolError): """Raised for solver execution errors.""" +class LineageFilterError(ValueError): + """Raised when the recourse scenario-lineage filter cannot be applied + soundly (Slice D §6). + + Subclasses ``ValueError`` so the Slice B lineage guards + (``_derived_walks._assert_lineage_castable``, + ``_derived_branch.assert_recourse_npv_preconditions``) can raise it + without churning their ``pytest.raises(ValueError)`` pins. Its + dedicated type lets the four blanket ``except Exception`` swallows in + ``apply_derived_c`` / ``apply_synthetic_invest_sets`` re-raise lineage + failures loudly instead of silently reverting to the unfiltered + first-stage sets — the exact silent failure the recourse guards exist + to prevent. + """ + + # --------------------------------------------------------------------------- # Lightweight value types # --------------------------------------------------------------------------- diff --git a/tests/engine_polars/test_recourse_lineage_error.py b/tests/engine_polars/test_recourse_lineage_error.py new file mode 100644 index 000000000..b60c657a6 --- /dev/null +++ b/tests/engine_polars/test_recourse_lineage_error.py @@ -0,0 +1,49 @@ +"""Slice D — Phase D1 β narrowing: LineageFilterError. + +Design: ``specs/sliceD_recourse_invest_design.md`` §6 / §18 D1. +The four blanket ``except Exception`` swallows in ``apply_derived_c`` / +``apply_synthetic_invest_sets`` must re-raise a lineage failure instead +of silently reverting to the unfiltered first-stage sets. This pins: + +* ``LineageFilterError`` is a ``ValueError`` subclass (keeps Slice B's + ``pytest.raises(ValueError)`` pins green), and +* the two Slice B lineage guards raise the precise type. + +The apply-site re-raise itself is exercised end-to-end once lineage is +active (D4); at D1 the raise path is unreachable (lineage is None), so +this file pins the type contract that makes the re-raise selective. +""" +from __future__ import annotations + +import polars as pl +import pytest + +from flextool.engine_polars._solve_state import LineageFilterError + + +def test_lineage_filter_error_is_valueerror(): + assert issubclass(LineageFilterError, ValueError) + + +def test_assert_lineage_castable_raises_lineage_filter_error(): + from flextool.engine_polars._derived_walks import _assert_lineage_castable + + enum_d = pl.Enum(["p2035", "p2040"]) + lineage = pl.DataFrame({ + "d": pl.Series("d", ["p2035", "p9999"], dtype=pl.Utf8), + "d_other": pl.Series("d_other", ["p2035", "p2040"], dtype=pl.Utf8), + }) + with pytest.raises(LineageFilterError, match="p9999"): + _assert_lineage_castable(lineage, enum_d) + + +def test_assert_recourse_preconditions_raises_lineage_filter_error( + monkeypatch): + from flextool.engine_polars import _derived_branch + + monkeypatch.setattr( + _derived_branch, "check_recourse_npv_preconditions", + lambda *a, **k: ["synthetic violation for the test"], + ) + with pytest.raises(LineageFilterError, match="preconditions violated"): + _derived_branch.assert_recourse_npv_preconditions(None) From c318bb38be28a1c0b03af7f92dd3defd41efccca Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 00:05:27 +0300 Subject: [PATCH 10/87] feat(stochastic): D2 invest axis + per-period anchor-mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (A) _expand_invest_branch_periods — invest-side mirror of the dispatch _expand_branch_periods; wraps the 5 axis-defining _solve_periods(invest_periods) sites (ed_invest_set, ed_divest_set, ed_invest_forbidden, eager ed_entity_annual_family, apply_npv) so period_invest gains {p_k, p_k_b} under recourse. ctx/provider threaded through those readers + their apply_derived_c / apply_synthetic callers. period_first + PIU-fallback sites left unwrapped (design §3.1). (B) _anchor_expand_explicit + module-scoped anchor-pairs holder set at each Layer-4 boundary (apply_derived_c / apply_npv / apply_synthetic): explicit per-period rows (invest_cost/discount/lifetime/fixed_cost Maps + per-period caps) inherit their anchor's value onto branch periods, wired into the three producers (_resolve_per_period_lf, _per_entity_period_cost, _ed_explicit_period_param). Holder None flag-off/deterministic -> no-op -> byte-parity. Flag-off spot-suites (fanout, npv_cluster, lineage) green; new axis unit tests cover expansion + anchor inheritance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_npv.py | 16 +- flextool/engine_polars/_derived_params.py | 195 ++++++++++++++++-- .../test_recourse_invest_axis.py | 120 +++++++++++ 3 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 tests/engine_polars/test_recourse_invest_axis.py diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 52c43af48..15ee7b0b3 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -249,9 +249,11 @@ def _resolve_per_period_lf(per_param: pl.LazyFrame, Returns ``ed_lf`` with an extra ``value`` column (Float64). ``ed_lf`` must carry columns ``[e, d, ...]``. """ - explicit = (per_param - .filter(~pl.col("is_scalar")) - .select("e", "d", pl.col("value").alias("v_explicit"))) + from ._derived_params import _anchor_expand_explicit + explicit = _anchor_expand_explicit( + per_param + .filter(~pl.col("is_scalar")) + .select("e", "d", pl.col("value").alias("v_explicit"))) scalar = (per_param .filter(pl.col("is_scalar")) .select("e", pl.col("value").alias("v_scalar"))) @@ -1277,12 +1279,18 @@ def apply_npv(flex_data: object, from ._derived_params import ( _read_active_solve, _solve_periods, _period_in_use_set, _periodAll_from_source, _read_period_with_history, + _expand_invest_branch_periods, _enter_recourse_anchor_scope, ) + # Slice D §4 — arm the per-period anchor-map scope for the annuity / + # lifetime / fixed-cost resolution below (no-op flag-off). + _enter_recourse_anchor_scope(workdir, provider=provider) active_solve = _read_active_solve(workdir, provider=provider) period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) period_universe = _periodAll_from_source(source, active_solve, workdir=workdir, provider=provider) - period_invest = _solve_periods(source, active_solve, "invest_periods") or [] + period_invest = _expand_invest_branch_periods( + _solve_periods(source, active_solve, "invest_periods"), + workdir, provider=provider) or [] period_with_history = (_read_period_with_history(workdir, provider=provider) or list(period_in_use)) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 96fd0a588..411539edc 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -142,6 +142,118 @@ def _recourse_invest_active( return str(val).strip().lower() == "recourse" if val is not None else False +# ─── Slice D (B, §4) — per-period parameter anchor-mapping ───────────── +# Module-level holder for the (anchor, br) synthetic-branch pairs of the +# ACTIVE solve. Set fresh at the top of every Layer-4 apply boundary +# (``apply_derived_c`` / ``apply_npv`` / ``apply_synthetic_invest_sets``) +# to the recourse pairs, or ``None`` when the flag is off / deterministic. +# Read by the three explicit-per-period producers (_resolve_per_period_lf, +# _per_entity_period_cost, _ed_explicit_period_param) to inherit each +# anchor's per-(e,d) value onto its branch periods (§4.1). ``None`` == +# no-op == flag-off byte-parity. The set-at-boundary-top invariant means +# a stale value can never reach a producer: every boundary overwrites it +# before any producer runs, and producers run nowhere else. +_RECOURSE_ANCHOR_PAIRS: "pl.DataFrame | None" = None + + +def _build_recourse_anchor_pairs( + workdir: Path | None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> "pl.DataFrame | None": + """(anchor, br) pairs for in-use synthetic branches under recourse. + + Returns ``None`` when the recourse flag is off (byte-parity default). + Mirrors :func:`_expand_branch_periods`' acquisition (ctx-first, then + provider/workdir ``period__branch`` + ``period_in_use``); keeps only + synthetic (``anchor != br``) branches that carry LP variables (``br`` + in ``period_in_use``). + """ + if not _recourse_invest_active(workdir, ctx=ctx, provider=provider): + return None + pb_df: "pl.DataFrame | None" = None + in_use: set[str] = set() + if ctx is not None: + pb_ctx = ctx.period_branch + if pb_ctx.height > 0: + pb_df = pb_ctx.rename({"d_anchor": "anchor", "b": "br"}) + piu_ctx = ctx.period_in_use + if piu_ctx.height > 0: + in_use = set(piu_ctx["d"].to_list()) + if pb_df is None: + if workdir is None: + return None + p = Path(workdir) / "solve_data" / "period__branch.csv" + if not _provider_has_key(provider, p): + return None + pb_df = _provider_read(provider, p) + if pb_df.height == 0: + return None + if not in_use: + piu_path = Path(workdir) / "solve_data" / "period_in_use_set.csv" + if _provider_has_key(provider, piu_path): + piu = _provider_read(provider, piu_path) + if piu.height > 0: + in_use = set(piu["period"].to_list()) + pb_df = pb_df.rename({"period": "anchor", "branch": "br"}) + out = (pb_df.select( + pl.col("anchor").cast(pl.Utf8), + pl.col("br").cast(pl.Utf8)) + .filter(pl.col("anchor") != pl.col("br"))) + if in_use: + out = out.filter(pl.col("br").is_in(list(in_use))) + out = out.unique() + return out if out.height > 0 else None + + +def _enter_recourse_anchor_scope( + workdir: Path | None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> None: + """Set :data:`_RECOURSE_ANCHOR_PAIRS` for the active solve. + + Called at the top of each Layer-4 apply boundary BEFORE any explicit- + per-period producer runs. Overwrites unconditionally (to ``None`` + when flag-off) so no stale value from a prior solve can leak. + """ + global _RECOURSE_ANCHOR_PAIRS + _RECOURSE_ANCHOR_PAIRS = _build_recourse_anchor_pairs( + workdir, ctx=ctx, provider=provider) + + +def _anchor_expand_explicit(explicit_lf: pl.LazyFrame) -> pl.LazyFrame: + """§4.1 — add branch rows inheriting each anchor's per-(e, d) value. + + ``explicit_lf`` is an ``(e, d, )`` lazy frame keyed by REAL + anchor periods (any value-column name(s); all are preserved). When + the recourse anchor-pairs holder is set, each + ``(e, anchor, v)`` row spawns ``(e, br, v)`` for every in-use branch + ``br`` of ``anchor``; a branch that already carries its OWN explicit + row keeps it (branch-native wins, via order-preserving dedupe). + Holder ``None`` (flag-off / deterministic) → returns the frame + unchanged (byte-parity). Operates in Utf8 on ``d`` then restores the + original dtype so an Enum-vocab mismatch cannot silently null a token. + """ + pairs = _RECOURSE_ANCHOR_PAIRS + if pairs is None or pairs.height == 0: + return explicit_lf + d_dtype = explicit_lf.collect_schema().get("d") + base = explicit_lf.with_columns(pl.col("d").cast(pl.Utf8)) + inherited = (base.join(pairs.lazy(), + left_on="d", right_on="anchor", how="inner") + .drop("d") + .rename({"br": "d"}) + .select(base.collect_schema().names())) + combined = pl.concat([base, inherited], how="vertical_relaxed") + out = combined.unique(subset=["e", "d"], keep="first", maintain_order=True) + if d_dtype is not None: + out = out.with_columns(pl.col("d").cast(d_dtype, strict=False)) + return out + + def _has_branch_invest_axis(flex_data: "object | None") -> bool: """Capability conjunct (Slice B §8, design §2): True iff the invest axis actually carries synthetic branch members. @@ -3303,7 +3415,9 @@ def _per_entity_period_cost(source: "InputSource", ).join(pi_lf, how="cross")) if not parts: return None - return pl.concat(parts).unique() + # Slice D §4 — anchor-map period-Map rows onto branch periods so the + # eea predicate keeps branch (e, d) pairs (no-op flag-off). + return _anchor_expand_explicit(pl.concat(parts).unique()) # --------------------------------------------------------------------------- @@ -3459,6 +3573,9 @@ def ed_invest_forbidden_no_investment_from_source( active_solve: str | None, workdir: Path | None, ed_invest: pl.DataFrame | None = None, # noqa: ARG001 — kept for back-compat + *, + ctx: "SolveContext | None" = None, + provider: "object | None" = None, ) -> pl.DataFrame | None: """Audit §3.7 — ``ed_invest`` rows whose ``no_investment`` lifetime window has already ended. @@ -3478,7 +3595,9 @@ def ed_invest_forbidden_no_investment_from_source( * ``e`` has ``lifetime_method == 'no_investment'``, * ``yr[d] >= life_sum(e)``. """ - period_invest = _solve_periods(source, active_solve, "invest_periods") + period_invest = _expand_invest_branch_periods( + _solve_periods(source, active_solve, "invest_periods"), + workdir, ctx=ctx, provider=provider) if not period_invest: return pl.DataFrame(schema={"e": schema_dtype(_enums, "e"), "d": schema_dtype(_enums, "d")}) @@ -3507,6 +3626,9 @@ def ed_invest_forbidden_no_investment_from_source( def ed_invest_set_from_source(source: "InputSource", active_solve: str | None, workdir: Path | None = None, + *, + ctx: "SolveContext | None" = None, + provider: "object | None" = None, ) -> pl.DataFrame | None: """Compute the (entity, period) pairs where invest is allowed. @@ -3526,7 +3648,9 @@ def ed_invest_set_from_source(source: "InputSource", ``invest_divest_sets.py:write_ed_invest_forbidden_no_investment`` consumed by ``fix_v_invest_no_investment_eq`` (mod L3930). """ - period_invest = _solve_periods(source, active_solve, "invest_periods") + period_invest = _expand_invest_branch_periods( + _solve_periods(source, active_solve, "invest_periods"), + workdir, ctx=ctx, provider=provider) if not period_invest: # Empty invest_periods → empty ed_invest (the dispatch-only solve case). return pl.DataFrame(schema={"e": schema_dtype(_enums, "e"), @@ -3568,6 +3692,10 @@ def ed_invest_set_from_source(source: "InputSource", def ed_divest_set_from_source(source: "InputSource", active_solve: str | None, + workdir: Path | None = None, + *, + ctx: "SolveContext | None" = None, + provider: "object | None" = None, ) -> pl.DataFrame | None: """Mirror of ``ed_invest_set`` for divest. Algorithm (``invest_divest_sets.py:185-193``): @@ -3576,7 +3704,9 @@ def ed_divest_set_from_source(source: "InputSource", e ∈ entityDivest, d ∈ period_invest_of_solve, (eead[e, d] != 0 OR e has capacity constraint) } """ - period_invest = _solve_periods(source, active_solve, "invest_periods") + period_invest = _expand_invest_branch_periods( + _solve_periods(source, active_solve, "invest_periods"), + workdir, ctx=ctx, provider=provider) if not period_invest: return pl.DataFrame(schema={"e": schema_dtype(_enums, "e"), "d": schema_dtype(_enums, "d")}) @@ -4075,7 +4205,10 @@ def _ed_explicit_period_param(source: "InputSource", parameter_name: str return pl.LazyFrame(schema={"e": schema_dtype(_enums, "e"), "d": schema_dtype(_enums, "d"), "value": pl.Float64}) - return pl.concat(parts).unique(subset=["e", "d"], keep="last") + # Slice D §4 — anchor-map explicit per-period cap rows onto branch + # periods (invest_max_period / cumulative_max_capacity); no-op flag-off. + return _anchor_expand_explicit( + pl.concat(parts).unique(subset=["e", "d"], keep="last")) def p_entity_max_units_from_source(source: "InputSource", @@ -5358,6 +5491,9 @@ def apply_derived_c( # so the model layer reads ``d.recourse_invest`` without a workdir touch. flex_data.recourse_invest = _recourse_invest_active( workdir, ctx=ctx, provider=provider) + # Slice D §4 — arm the per-period anchor-map scope for the invest/ + # divest set predicates + cap readers built below (no-op flag-off). + _enter_recourse_anchor_scope(workdir, ctx=ctx, provider=provider) # Δ.12b — assignment is unconditional except for fields with # documented helper-coverage gaps (multi-year cascade extends @@ -5440,11 +5576,13 @@ def apply_derived_c( # ─── §3.7 invest / divest ───────────────────────────────────────── # Δ.12b — set frames (None or non-empty); keep height>0 as a # structural filter to preserve the SET-frame contract. - ed_inv_db = ed_invest_set_from_source(source, active_solve, workdir) + ed_inv_db = ed_invest_set_from_source( + source, active_solve, workdir, ctx=ctx, provider=provider) if ed_inv_db is not None and ed_inv_db.height > 0: flex_data.ed_invest_set = ed_inv_db - ed_div_db = ed_divest_set_from_source(source, active_solve) + ed_div_db = ed_divest_set_from_source( + source, active_solve, workdir, ctx=ctx, provider=provider) if ed_div_db is not None and ed_div_db.height > 0: flex_data.ed_divest_set = ed_div_db @@ -5504,7 +5642,8 @@ def apply_derived_c( ed_inv_db if ed_inv_db is not None else getattr(flex_data, "ed_invest_set", None)) forbidden_db = ed_invest_forbidden_no_investment_from_source( - source, active_solve, workdir, ed_invest_for_forbidden) + source, active_solve, workdir, ed_invest_for_forbidden, + ctx=ctx, provider=provider) if forbidden_db is not None: flex_data.ed_invest_forbidden_no_investment = ( forbidden_db if forbidden_db.height > 0 else None) @@ -6176,6 +6315,31 @@ def _expand_branch_periods(period_order: list[str], return out +def _expand_invest_branch_periods(period_invest: "list[str] | None", + workdir: Path | None, + *, + ctx: "SolveContext | None" = None, + provider: "object | None" = None, + ) -> "list[str] | None": + """Slice D (A, §3.1) — invest-side mirror of + :func:`_expand_branch_periods`. + + When the recourse flag is active, append each real invest period's + synthetic branch members (filtered to ``period_in_use``) after the + anchor, so ``period_invest`` carries ``{p_k, p_k_b, …}`` — the axis + on which ``pd/nd_invest_set`` (and thus ``v_invest``) get their + columns. Flag-off (or empty axis) → returns ``period_invest`` + unchanged (byte-parity). Reuses — does not fork — the dispatch-side + helper so invest and dispatch axes stay consistent. + """ + if not period_invest: + return period_invest + if not _recourse_invest_active(workdir, ctx=ctx, provider=provider): + return period_invest + return _expand_branch_periods(list(period_invest), workdir, + ctx=ctx, provider=provider) + + def _dt_period_active_steps_from_workdir( source: "InputSource", workdir: Path, @@ -8906,6 +9070,7 @@ def ed_entity_annual_family_from_source(source: "InputSource", ed_divest: pl.DataFrame | None, workdir: Path | None = None, *, + ctx: "SolveContext | None" = None, provider: "object | None" = None, ) -> dict[str, "Param | None"]: """Compute ``ed_entity_annual``, ``ed_entity_annual_discounted``, @@ -8919,7 +9084,9 @@ def ed_entity_annual_family_from_source(source: "InputSource", """ factors = _inflation_yearly_from_source(source, active_solve, workdir) period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) - period_invest = _solve_periods(source, active_solve, "invest_periods") or [] + period_invest = _expand_invest_branch_periods( + _solve_periods(source, active_solve, "invest_periods"), + workdir, ctx=ctx, provider=provider) or [] cost_invest = _per_entity_period_value(source, "invest_cost") cost_invest_scalar = _per_entity_scalar(source, "invest_cost") @@ -9859,13 +10026,17 @@ def apply_synthetic_invest_sets(flex_data: object, (deferred per the Gap D dispatch). """ base, anchor = synthetic + # Slice D §4 — arm the per-period anchor-map scope (no-op flag-off). + _enter_recourse_anchor_scope(workdir, provider=provider) # ed_invest_set / ed_divest_set — eager helpers use _solve_periods # which is synthetic-aware (Δ.19); pass the synthetic name through. # Γ.6.D forbidden filter is applied internally by the helper. - ed_inv = ed_invest_set_from_source(source, active_solve, workdir=workdir) + ed_inv = ed_invest_set_from_source( + source, active_solve, workdir=workdir, provider=provider) if ed_inv is not None and ed_inv.height > 0: flex_data.ed_invest_set = ed_inv - ed_div = ed_divest_set_from_source(source, active_solve) + ed_div = ed_divest_set_from_source( + source, active_solve, workdir, provider=provider) if ed_div is not None and ed_div.height > 0: flex_data.ed_divest_set = ed_div @@ -9873,7 +10044,7 @@ def apply_synthetic_invest_sets(flex_data: object, # gate; consumed by apply_existing_chain. try: forbidden = ed_invest_forbidden_no_investment_from_source( - source, active_solve, workdir, ed_inv) + source, active_solve, workdir, ed_inv, provider=provider) except Exception: # pragma: no cover — defensive forbidden = None if forbidden is not None and forbidden.height > 0: diff --git a/tests/engine_polars/test_recourse_invest_axis.py b/tests/engine_polars/test_recourse_invest_axis.py new file mode 100644 index 000000000..b44920972 --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_axis.py @@ -0,0 +1,120 @@ +"""Slice D — Phase D2: invest-axis branch expansion + per-period anchor map. + +Design: ``specs/sliceD_recourse_invest_design.md`` §3 (A) / §4 (B) / §18 D2. +Solver-free unit coverage of the two mechanisms; the full flag-on cascade +objective gate lands at D5. +""" +from __future__ import annotations + +import polars as pl +import pytest + +import flextool.engine_polars._derived_params as dp +from flextool.engine_polars._derived_params import ( + _anchor_expand_explicit, + _build_recourse_anchor_pairs, + _expand_invest_branch_periods, +) +from flextool.engine_polars._emit_solve_writers import ( + emit_stochastic_invest_method, +) + + +@pytest.fixture(autouse=True) +def _reset_anchor_scope(): + """Never leak the module-global anchor-pairs holder to another test.""" + dp._RECOURSE_ANCHOR_PAIRS = None + yield + dp._RECOURSE_ANCHOR_PAIRS = None + + +def _emit(prov, key, cols, rows): + from flextool.engine_polars._emit_provider_io import _emit as _e + _e(prov, key, pl.DataFrame({c: [r[i] for r in rows] + for i, c in enumerate(cols)})) + + +def _provider(tmp_path, *, flag: str, pb, piu): + from flextool.engine_polars._flex_data_provider import FlexDataProvider + prov = FlexDataProvider() + emit_stochastic_invest_method( + flag, str(tmp_path / "solve_data/stochastic_invest_method.csv"), + provider=prov) + _emit(prov, "solve_data/period__branch.csv", ("period", "branch"), pb) + _emit(prov, "solve_data/period_in_use_set.csv", ("period",), + [(p,) for p in piu]) + return prov + + +PB = [ + ("p2035", "p2035"), ("p2035", "p2035_rlz"), ("p2035", "p2035_low"), + ("p2040", "p2040"), ("p2040", "p2040_rlz"), ("p2040", "p2040_low"), +] +PIU = ["p2035", "p2035_low", "p2040", "p2040_low"] + + +# --------------------------------------------------------------------------- +# (A) invest-side branch expansion +# --------------------------------------------------------------------------- + + +def test_expand_invest_axis_recourse(tmp_path): + prov = _provider(tmp_path, flag="recourse", pb=PB, piu=PIU) + out = _expand_invest_branch_periods( + ["p2035", "p2040"], tmp_path, provider=prov) + assert out == ["p2035", "p2035_low", "p2040", "p2040_low"] + + +def test_expand_invest_axis_flag_off_noop(tmp_path): + prov = _provider(tmp_path, flag="none", pb=PB, piu=PIU) + out = _expand_invest_branch_periods( + ["p2035", "p2040"], tmp_path, provider=prov) + assert out == ["p2035", "p2040"] + + +def test_expand_invest_axis_empty_passthrough(tmp_path): + prov = _provider(tmp_path, flag="recourse", pb=PB, piu=PIU) + assert _expand_invest_branch_periods([], tmp_path, provider=prov) == [] + assert _expand_invest_branch_periods(None, tmp_path, provider=prov) is None + + +# --------------------------------------------------------------------------- +# (B) anchor pairs + explicit expansion +# --------------------------------------------------------------------------- + + +def test_anchor_pairs_recourse(tmp_path): + prov = _provider(tmp_path, flag="recourse", pb=PB, piu=PIU) + pairs = _build_recourse_anchor_pairs(tmp_path, provider=prov) + got = {(r["anchor"], r["br"]) for r in pairs.iter_rows(named=True)} + # only in-use synthetic branches (p2035_rlz not in PIU → excluded). + assert got == {("p2035", "p2035_low"), ("p2040", "p2040_low")} + + +def test_anchor_pairs_flag_off_none(tmp_path): + prov = _provider(tmp_path, flag="none", pb=PB, piu=PIU) + assert _build_recourse_anchor_pairs(tmp_path, provider=prov) is None + + +def test_anchor_expand_inherits_branch_rows(tmp_path): + prov = _provider(tmp_path, flag="recourse", pb=PB, piu=PIU) + dp._enter_recourse_anchor_scope(tmp_path, provider=prov) + explicit = pl.LazyFrame({ + "e": ["peaker", "peaker"], + "d": ["p2035", "p2040"], + "value": [7.0, 9.0], + }) + out = _anchor_expand_explicit(explicit).collect().sort("e", "d") + got = {(r["d"], r["value"]) for r in out.iter_rows(named=True)} + assert got == { + ("p2035", 7.0), ("p2035_low", 7.0), + ("p2040", 9.0), ("p2040_low", 9.0), + } + + +def test_anchor_expand_noop_when_scope_none(tmp_path): + prov = _provider(tmp_path, flag="none", pb=PB, piu=PIU) + dp._enter_recourse_anchor_scope(tmp_path, provider=prov) # sets None + explicit = pl.LazyFrame({"e": ["peaker"], "d": ["p2035"], "value": [7.0]}) + out = _anchor_expand_explicit(explicit).collect() + assert out.height == 1 and out["d"].to_list() == ["p2035"] From 8aef02105d09e8ae1f96f18d56b73af74cdbe0db Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 00:15:47 +0300 Subject: [PATCH 11/87] feat(stochastic): D3 alpha year/factor provider threading Thread provider through period_walk_iterator into _p_years_d_lf (year side) and _inflation_factors_lf -> _years_for_period_lf -> _years_for_period_from_source (factor side). Add a provider-gated canonical arm to _years_for_period_from_source reading the emitted p_years_represented.csv (which byte-copies fan-member year rows via derive_years_represented's branch loop). Under recourse the caller (D4 apply boundaries) forwards provider so branch periods resolve their anchor's year-from-start -> correct annuity windows; provider=None (flag-off) keeps the canonical arms dead -> today's fill_null behaviour (the W6 today-bug pin) -> byte-parity. Plumbing only this phase (no caller forwards provider yet); activation lands with lineage at D4. Flag-off npv_cluster/walk/fanout suites green; new canonical-arm unit tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_npv.py | 13 ++- flextool/engine_polars/_derived_params.py | 23 +++++ flextool/engine_polars/_derived_walks.py | 11 ++- .../test_recourse_year_threading.py | 84 +++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 tests/engine_polars/test_recourse_year_threading.py diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 15ee7b0b3..56274b0e1 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -370,17 +370,21 @@ def _explicit_max(par: str, default: float) -> float: def _years_for_period_lf(source: "InputSource", active_solve: str | None, period_universe: list[str], + *, + provider: "object | None" = None, ) -> pl.LazyFrame: """Build the per-(d, year_label, width) frame from ``solve.years_represented`` mirroring :func:`._derived_params._years_for_period_from_source` in lazy form. Returns columns ``[d, y, width]`` where ``y`` is a string label. - Empty if no rows. + Empty if no rows. ``provider`` (Slice D α-1) revives the canonical + ``p_years_represented.csv`` arm carrying fan-member year rows. """ # Materialise scalars eagerly — they're solve-level, tiny. from ._derived_params import _years_for_period_from_source - yfp = _years_for_period_from_source(source, active_solve, period_universe) + yfp = _years_for_period_from_source( + source, active_solve, period_universe, provider=provider) rows: list[tuple[str, str, float]] = [] for d, years in yfp.items(): for y, w in years: @@ -397,6 +401,8 @@ def _years_for_period_lf(source: "InputSource", def _inflation_factors_lf(source: "InputSource", active_solve: str | None, period_universe: list[str], + *, + provider: "object | None" = None, ) -> pl.LazyFrame: """Lazy ``(d, inv_factor, ops_factor)`` frame. @@ -426,7 +432,8 @@ def _inflation_factors_lf(source: "InputSource", }) rate, off_inv, off_ops = _solve_inflation_scalars(source) one_plus_inv = (1.0 / (1.0 + rate)) if rate != -1.0 else 1.0 - yfp_lf = _years_for_period_lf(source, active_solve, period_universe) + yfp_lf = _years_for_period_lf( + source, active_solve, period_universe, provider=provider) # Materialise per-period × per-y. At LP scale this is small (typically # ≤ 100 (d, y) rows even for 50-year horizon × 20 representative years), diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 411539edc..baad7504a 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -8382,6 +8382,8 @@ def _explicit_max(par: str, default: float) -> float: def _years_for_period_from_source(source: "InputSource", active_solve: str | None, period_set: list[str], + *, + provider: "object | None" = None, ) -> dict[str, list[tuple[str, float]]]: """Derive the per-period ``(year_label, width)`` list emitted into ``solve_data/p_years_represented.csv`` (matches the @@ -8404,6 +8406,27 @@ def _years_for_period_from_source(source: "InputSource", parameter is absent or active_solve has no rows, default to one row per period in the order supplied (width=1, year_label = "0", "1", …). """ + # Slice D α-1 (§5.1): under recourse the emitted + # ``p_years_represented.csv`` already byte-copies fan-member year rows + # (``derive_years_represented`` branch loop), so read it directly to + # give branch periods their anchor's (year_label, width) rows. The + # caller forwards ``provider`` ONLY on the recourse walker arm; every + # other caller passes ``provider=None`` so this arm stays dead and the + # source path below is byte-identical (flag-off parity). + if provider is not None: + p = Path("solve_data") / "p_years_represented.csv" + if _provider_has_key(provider, p): + df = _provider_read(provider, p) + need = {"period", "years_from_solve", "p_years_represented"} + if df.height > 0 and need.issubset(df.columns): + canon: dict[str, list[tuple[str, float]]] = {} + for r in df.iter_rows(named=True): + canon.setdefault(str(r["period"]), []).append( + (str(r["years_from_solve"]), + float(r["p_years_represented"]))) + if canon: + return canon + yr_p = _try_param(source, "solve", "years_represented") out: dict[str, list[tuple[str, float]]] = {} if active_solve is None or yr_p is None or "period" not in yr_p.columns: diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index 0595b272f..82767a71c 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -196,6 +196,7 @@ def period_walk_iterator( factor_side: str | None, workdir = None, lineage: pl.DataFrame | None = None, + provider: "object | None" = None, ) -> pl.LazyFrame: """Lazy per-(e, d) walk over ``period_in_use``, gated by lifetime. @@ -270,7 +271,12 @@ def period_walk_iterator( factor=pl.lit(0.0, dtype=pl.Float64)) # Lazy import to avoid circular dependency at module-load time. from ._derived_params import _p_years_d_lf - pyd_lf = _p_years_d_lf(source, active_solve, workdir) + # Slice D α-1 (§5.1): under recourse the caller forwards ``provider`` so + # the canonical ``p_years_d.csv`` arm — which byte-copies fan-member year + # rows — goes live, giving branch periods their anchor's year-from-start + # (correct annuity windows). ``provider is None`` → arm dead → today's + # fill_null(0.0) behaviour → flag-off byte-parity (the W6 today-bug pin). + pyd_lf = _p_years_d_lf(source, active_solve, workdir, provider=provider) if pyd_lf is None: # Without years offsets, the integral collapses to 0 / no rows. if factor_side is None: @@ -365,7 +371,8 @@ def period_walk_iterator( # Inflation factor sum. from ._derived_npv import _inflation_factors_lf - factors_lf = _inflation_factors_lf(source, active_solve, period_universe) + factors_lf = _inflation_factors_lf( + source, active_solve, period_universe, provider=provider) if factor_side == "inv": factors_lf = factors_lf.select( "d", pl.col("inv_factor").alias("factor")) diff --git a/tests/engine_polars/test_recourse_year_threading.py b/tests/engine_polars/test_recourse_year_threading.py new file mode 100644 index 000000000..d8d355546 --- /dev/null +++ b/tests/engine_polars/test_recourse_year_threading.py @@ -0,0 +1,84 @@ +"""Slice D — Phase D3: α-1 year/factor provider threading. + +Design: ``specs/sliceD_recourse_invest_design.md`` §5 / §18 D3. +Under recourse the walker forwards ``provider`` so the canonical +``p_years_d.csv`` / ``p_years_represented.csv`` arms — which byte-copy the +fan-member year rows — go live, giving branch periods their anchor's +year-from-start. ``provider=None`` (flag-off) → arms dead → today's +behaviour (the W6 today-bug pin). These are the two canonical-arm reads, +unit-tested; the full edd cross-leaf / objective effect lands at D4/D5. +""" +from __future__ import annotations + +import polars as pl + +from flextool.engine_polars._derived_params import ( + _p_years_d_lf, + _years_for_period_from_source, +) + + +def _emit(prov, key, df): + from flextool.engine_polars._emit_provider_io import _emit as _e + _e(prov, key, df) + + +def _provider(): + from flextool.engine_polars._flex_data_provider import FlexDataProvider + return FlexDataProvider() + + +# --------------------------------------------------------------------------- +# Year side — _p_years_d_lf canonical arm (already provider-gated; verify the +# fan-member rows resolve when provider is present and stay absent when not). +# --------------------------------------------------------------------------- + + +def test_p_years_d_canonical_arm_reads_branch_rows(tmp_path): + prov = _provider() + _emit(prov, "solve_data/p_years_d.csv", pl.DataFrame({ + "period": ["p2035", "p2035_low", "p2040", "p2040_low"], + "value": ["0", "0", "1", "1"], + })) + # The canonical arm builds its provider key from ``workdir``; the disk + # file need not exist (provider holds the frame). + out = _p_years_d_lf(None, "s", tmp_path, provider=prov) + assert out is not None + got = {r["d"]: r["yr"] + for r in out.collect().iter_rows(named=True)} + assert got == {"p2035": 0.0, "p2035_low": 0.0, + "p2040": 1.0, "p2040_low": 1.0} + + +# --------------------------------------------------------------------------- +# Factor side — _years_for_period_from_source canonical arm. +# --------------------------------------------------------------------------- + + +def test_years_for_period_canonical_arm_branch_rows(): + prov = _provider() + _emit(prov, "solve_data/p_years_represented.csv", pl.DataFrame({ + "period": ["p2035", "p2035_low", "p2040", "p2040_low"], + "years_from_solve": ["0", "0", "1", "1"], + "p_years_from_solve": ["0", "0", "1", "1"], + "p_years_represented": ["1.0", "1.0", "1.0", "1.0"], + })) + out = _years_for_period_from_source( + None, "s", ["p2035", "p2035_low", "p2040", "p2040_low"], + provider=prov) + assert out["p2035_low"] == [("0", 1.0)] + assert out["p2040_low"] == [("1", 1.0)] + + +def test_years_for_period_provider_without_csv_falls_through(): + # provider present but no p_years_represented.csv key → canonical arm + # skipped. (The source-path fallback is covered by the flag-off golden + # spot-suites, which pass a real InputSource.) + from flextool.engine_polars._flex_data_provider import FlexDataProvider + prov = FlexDataProvider() + p = "solve_data/p_years_represented.csv" + from flextool.engine_polars._derived_params import ( + _provider_has_key as _hk, + ) + from pathlib import Path + assert _hk(prov, Path(p)) is False From 4e39d2c992ee37483953c9650ccaab40618cd7a9 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 00:22:49 +0300 Subject: [PATCH 12/87] feat(stochastic): D4 lineage activation + NPV preconditions Compute the scenario-lineage frame (flex_data.dd_same_scenario) at the three Layer-4 apply boundaries, gated on recourse AND a genuine branch invest axis (capability conjunct). Forward lineage=... into the edd builders threaded by Slice B: edd_invest_lookback_set, edd_invest_set_lf, edd_divest_active (#18), the synthetic edd, and apply_npv's four ed_*_from_source walks. Hoist assert_recourse_npv_preconditions above the edd try/except blocks so a violation raises LineageFilterError (loud, D1). alpha-1 walker Provider: a boundary-scoped module-global (_RECOURSE_WALK_PROVIDER) armed alongside the anchor-pairs scope; period_walk_iterator falls back to it so the canonical year/factor arms revive under the flag without threading provider through ~12 NPV/edd caller signatures. None flag-off -> arms dead -> W6 today-bug preserved. Activation is inert flag-off (lineage None) -> byte-parity; 103-test spot-suite (npv_cluster/fanout/lineage/axis/year) green. Flag-on edd cross-leaf integration lands at D5 (Guard 1 lift + fixture). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_npv.py | 30 +++++++-- flextool/engine_polars/_derived_params.py | 66 ++++++++++++++++--- flextool/engine_polars/_derived_walks.py | 8 +++ .../test_recourse_invest_axis.py | 4 +- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 56274b0e1..0bd1239ca 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -1287,12 +1287,26 @@ def apply_npv(flex_data: object, _read_active_solve, _solve_periods, _period_in_use_set, _periodAll_from_source, _read_period_with_history, _expand_invest_branch_periods, _enter_recourse_anchor_scope, + _recourse_invest_active, _has_branch_invest_axis, ) - # Slice D §4 — arm the per-period anchor-map scope for the annuity / - # lifetime / fixed-cost resolution below (no-op flag-off). + # Slice D §4 — arm the per-period anchor-map + walker-Provider scope for + # the annuity / lifetime / fixed-cost resolution below (no-op flag-off). _enter_recourse_anchor_scope(workdir, provider=provider) active_solve = _read_active_solve(workdir, provider=provider) + # Slice D §8 (E) — scenario-lineage frame for the four NPV walks (gated + # on recourse AND a genuine branch invest axis). The precondition check + # is hoisted here, above the (unwrapped) NPV builder calls (§6). ``None`` + # flag-off → walks keep today's unfiltered behaviour → byte-parity. + _npv_lineage = ( + getattr(flex_data, "dd_same_scenario", None) + if _recourse_invest_active(workdir, provider=provider) + and _has_branch_invest_axis(flex_data) + else None) + if _npv_lineage is not None: + from ._derived_branch import assert_recourse_npv_preconditions + assert_recourse_npv_preconditions( + None, source, active_solve, provider=provider) period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) period_universe = _periodAll_from_source(source, active_solve, workdir=workdir, provider=provider) period_invest = _expand_invest_branch_periods( @@ -1336,22 +1350,26 @@ def _set_if(field: str, value): _set_if("ed_entity_annual_discounted", ed_entity_annual_discounted_from_source( source, active_solve, - period_invest, period_in_use, period_universe)) + period_invest, period_in_use, period_universe, + lineage=_npv_lineage)) _set_if("ed_entity_annual_divest_discounted", ed_entity_annual_divest_discounted_from_source( source, active_solve, - period_invest, period_in_use, period_universe)) + period_invest, period_in_use, period_universe, + lineage=_npv_lineage)) _set_if("ed_lifetime_fixed_cost", ed_lifetime_fixed_cost_from_source( source, active_solve, - period_with_history, period_in_use, period_universe)) + period_with_history, period_in_use, period_universe, + lineage=_npv_lineage)) _set_if("ed_lifetime_fixed_cost_divest", ed_lifetime_fixed_cost_divest_from_source( source, active_solve, - period_invest, period_in_use, period_universe)) + period_invest, period_in_use, period_universe, + lineage=_npv_lineage)) __all__ = [ diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index baad7504a..3b89091a2 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -155,6 +155,20 @@ def _recourse_invest_active( # before any producer runs, and producers run nowhere else. _RECOURSE_ANCHOR_PAIRS: "pl.DataFrame | None" = None +# Slice D α-1 — boundary-scoped Provider for the walker's canonical year/ +# factor arms. Set (to ``provider`` under recourse, else ``None``) at each +# Layer-4 boundary alongside the anchor pairs, so ``period_walk_iterator`` +# revives the canonical ``p_years_d.csv`` / ``p_years_represented.csv`` arms +# (correct branch-period years) WITHOUT threading ``provider`` through the +# ~12 NPV/edd walker-caller signatures. ``None`` (flag-off / deterministic) +# → arms dead → today's fill_null(0.0) walker behaviour → the W6 pin. +_RECOURSE_WALK_PROVIDER: "object | None" = None + + +def _recourse_walk_provider() -> "object | None": + """The active boundary's recourse walker Provider (α-1), or ``None``.""" + return _RECOURSE_WALK_PROVIDER + def _build_recourse_anchor_pairs( workdir: Path | None, @@ -216,12 +230,16 @@ def _enter_recourse_anchor_scope( """Set :data:`_RECOURSE_ANCHOR_PAIRS` for the active solve. Called at the top of each Layer-4 apply boundary BEFORE any explicit- - per-period producer runs. Overwrites unconditionally (to ``None`` - when flag-off) so no stale value from a prior solve can leak. + per-period producer OR walker runs. Overwrites unconditionally (to + ``None`` when flag-off) so no stale value from a prior solve can leak. + Also arms the boundary-scoped walker Provider (α-1). """ - global _RECOURSE_ANCHOR_PAIRS - _RECOURSE_ANCHOR_PAIRS = _build_recourse_anchor_pairs( - workdir, ctx=ctx, provider=provider) + global _RECOURSE_ANCHOR_PAIRS, _RECOURSE_WALK_PROVIDER + active = _recourse_invest_active(workdir, ctx=ctx, provider=provider) + _RECOURSE_WALK_PROVIDER = provider if active else None + _RECOURSE_ANCHOR_PAIRS = ( + _build_recourse_anchor_pairs(workdir, ctx=ctx, provider=provider) + if active else None) def _anchor_expand_explicit(explicit_lf: pl.LazyFrame) -> pl.LazyFrame: @@ -5494,6 +5512,21 @@ def apply_derived_c( # Slice D §4 — arm the per-period anchor-map scope for the invest/ # divest set predicates + cap readers built below (no-op flag-off). _enter_recourse_anchor_scope(workdir, ctx=ctx, provider=provider) + # Slice D §8 (E) — the scenario-lineage frame for the edd builders. + # Active only under recourse AND a genuine branch invest axis + # (capability conjunct §2). Hoist the precondition check ABOVE the + # edd try/except blocks (§6) so a LineageFilterError is loud, not + # swallowed. ``None`` flag-off → the edd builders keep today's + # unfiltered (all-pairs) behaviour → byte-parity. + _c_lineage = ( + getattr(flex_data, "dd_same_scenario", None) + if flex_data.recourse_invest and _has_branch_invest_axis(flex_data) + else None) + if _c_lineage is not None: + from flextool.engine_polars._derived_branch import ( + assert_recourse_npv_preconditions as _assert_recourse, + ) + _assert_recourse(None, source, active_solve, ctx=ctx, provider=provider) # Δ.12b — assignment is unconditional except for fields with # documented helper-coverage gaps (multi-year cascade extends @@ -5677,7 +5710,8 @@ def apply_derived_c( else getattr(flex_data, "ed_invest_set", None) try: eil_db = edd_invest_lookback_set_from_source( - source, active_solve, ed_inv_used, workdir) + source, active_solve, ed_inv_used, workdir, + provider=provider, lineage=_c_lineage) except LineageFilterError: raise except Exception: @@ -5699,7 +5733,8 @@ def apply_derived_c( try: edd_inv_db = _edd_invest_lf( source, active_solve, ed_inv_used.lazy(), - period_with_history, period_in_use, workdir).collect() + period_with_history, period_in_use, workdir, + lineage=_c_lineage).collect() except LineageFilterError: raise except Exception: @@ -5710,7 +5745,8 @@ def apply_derived_c( pd_div_used = getattr(flex_data, "pd_divest_set", None) try: edda_db = edd_divest_active_from_source( - source, active_solve, pd_div_used) + source, active_solve, pd_div_used, + provider=provider, lineage=_c_lineage) except LineageFilterError: raise except Exception: @@ -10107,10 +10143,22 @@ def apply_synthetic_invest_sets(flex_data: object, period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) period_with_history = (_read_period_with_history(workdir, provider=provider) or list(period_in_use)) + # Slice D §8 — synthetic edd lineage filter (gated + hoisted check). + _s_lineage = ( + getattr(flex_data, "dd_same_scenario", None) + if _recourse_invest_active(workdir, provider=provider) + and _has_branch_invest_axis(flex_data) + else None) + if _s_lineage is not None: + from flextool.engine_polars._derived_branch import ( + assert_recourse_npv_preconditions as _assert_recourse, + ) + _assert_recourse(None, source, active_solve, provider=provider) try: edd_inv = _edd_invest_lf( source, active_solve, ed_inv.lazy(), - period_with_history, period_in_use, workdir).collect() + period_with_history, period_in_use, workdir, + lineage=_s_lineage).collect() except LineageFilterError: raise except Exception: # pragma: no cover — defensive diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index 82767a71c..acf3417e7 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -261,6 +261,14 @@ def period_walk_iterator( triple). """ from flextool.engine_polars._axis_enums import empty_like + # Slice D α-1: fall back to the boundary-scoped recourse Provider so the + # canonical year/factor arms revive under the flag without threading + # ``provider`` through every NPV/edd walker caller. ``None`` off-flag. + if provider is None: + from flextool.engine_polars._derived_params import ( + _recourse_walk_provider, + ) + provider = _recourse_walk_provider() if not period_in_use: if factor_side is None: return empty_like(ed_lf, ["e", "d"], diff --git a/tests/engine_polars/test_recourse_invest_axis.py b/tests/engine_polars/test_recourse_invest_axis.py index b44920972..d13fc78be 100644 --- a/tests/engine_polars/test_recourse_invest_axis.py +++ b/tests/engine_polars/test_recourse_invest_axis.py @@ -22,10 +22,12 @@ @pytest.fixture(autouse=True) def _reset_anchor_scope(): - """Never leak the module-global anchor-pairs holder to another test.""" + """Never leak the module-global recourse holders to another test.""" dp._RECOURSE_ANCHOR_PAIRS = None + dp._RECOURSE_WALK_PROVIDER = None yield dp._RECOURSE_ANCHOR_PAIRS = None + dp._RECOURSE_WALK_PROVIDER = None def _emit(prov, key, cols, rows): From 51bf2d4ec49819857a96992ac2e4327b5dbd4f48 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 09:07:33 +0300 Subject: [PATCH 13/87] feat(stochastic): D5 objective weighting + Guard-1 lift + hand-calc gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Objective: multiply ALL EIGHT invest/divest cost Params (process annu/lf/lfd/annd + node annu_n/lf_n/lfd_n/annd_n) by pd_branch_weight[d] via the _wt closure, gated on d.recourse_invest AND pd_branch_weight; replace the .mod "NOT weighted" comment with the recourse rationale. Flag-off skips the multiply (byte-parity). Guard 1 LIFTED for 'recourse': _stochastic_invest_raw_guard validates the RAW authored value (resolver collapses typos to 'none' so the guard must read the raw dict); both sites (run() + _validate_model_solve) switched; guard tests flipped per design §15.4 + NEW raw-typo rejection test. Activation fixes found by the gate (recorded in design implementation notes): (a) precondition checker needs the REAL workdir (Provider-only readers key paths off it — design §8 snippet's None arg made obligations fail spuriously); (b) lineage built ON-DEMAND per boundary via dd_same_scenario_df (flex_data.dd_same_scenario populates only at derived_g); (c) alpha-1 walker holders gain the workdir twin (_RECOURSE_WALK_WORKDIR) so the canonical p_years_d arm goes live; (d) _recourse_usable_ctx sanitizes the snapshot-reload SolveContext (all-null d_anchor artifact) with provider-arm fall-through. Gate fixture stoch_two_period_invest.json (+ builder, + conftest wiring): supply-side branch variation (deterministic demand 100/130 + wind upper_limit profile rlz .4/.4 low .2/.1 via group__unit -> peaker residual 60/90 / 80/120) because node-inflow branch resolution gates on group__node and a stoch-group node would fire NA net-charge pinning. D5 gate: objective 196875 EXACT; coefficients 525/262.5/1575/787.5; per-leaf invest 60/80/30/40; committed output realized-only; edd cross-leaf ABSENT (W6 re-pointed); pd_non_anticipativity empty; STRICT autoscale clean. Flag-off parity: 128 tests green (lineage/walk/fanout/ guards/npv_cluster + recourse unit suites). ruff clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_npv.py | 30 +- flextool/engine_polars/_derived_params.py | 107 +- flextool/engine_polars/_derived_walks.py | 11 +- flextool/engine_polars/_orchestration.py | 66 +- flextool/engine_polars/model.py | 56 +- tests/conftest.py | 24 + tests/engine_polars/test_recourse_guards.py | 41 +- .../test_recourse_invest_gate.py | 159 + .../fixtures/build_stoch_two_period_invest.py | 241 + tests/fixtures/stoch_two_period_invest.json | 6091 +++++++++++++++++ 10 files changed, 6726 insertions(+), 100 deletions(-) create mode 100644 tests/engine_polars/test_recourse_invest_gate.py create mode 100644 tests/fixtures/build_stoch_two_period_invest.py create mode 100644 tests/fixtures/stoch_two_period_invest.json diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 0bd1239ca..4a9d357fb 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -1287,26 +1287,30 @@ def apply_npv(flex_data: object, _read_active_solve, _solve_periods, _period_in_use_set, _periodAll_from_source, _read_period_with_history, _expand_invest_branch_periods, _enter_recourse_anchor_scope, - _recourse_invest_active, _has_branch_invest_axis, + _recourse_branch_axis_present, ) # Slice D §4 — arm the per-period anchor-map + walker-Provider scope for # the annuity / lifetime / fixed-cost resolution below (no-op flag-off). _enter_recourse_anchor_scope(workdir, provider=provider) active_solve = _read_active_solve(workdir, provider=provider) - # Slice D §8 (E) — scenario-lineage frame for the four NPV walks (gated - # on recourse AND a genuine branch invest axis). The precondition check - # is hoisted here, above the (unwrapped) NPV builder calls (§6). ``None`` - # flag-off → walks keep today's unfiltered behaviour → byte-parity. - _npv_lineage = ( - getattr(flex_data, "dd_same_scenario", None) - if _recourse_invest_active(workdir, provider=provider) - and _has_branch_invest_axis(flex_data) - else None) - if _npv_lineage is not None: - from ._derived_branch import assert_recourse_npv_preconditions + # Slice D §8 (E) — scenario-lineage frame for the four NPV walks, gated + # on the anchor-pairs conjunct just armed (recourse-active AND branch + # axis). Built ON-DEMAND from the provider: ``flex_data.dd_same_scenario`` + # is not populated until ``apply_branch_cluster`` (derived_g), which runs + # AFTER apply_npv. Precondition check hoisted above the NPV builder + # calls (§6). ``None`` flag-off → walks unfiltered → byte-parity. + if _recourse_branch_axis_present(): + from ._derived_branch import ( + assert_recourse_npv_preconditions, + dd_same_scenario_df, + ) + _npv_lineage = dd_same_scenario_df( + workdir, source, active_solve, provider=provider) assert_recourse_npv_preconditions( - None, source, active_solve, provider=provider) + workdir, source, active_solve, provider=provider) + else: + _npv_lineage = None period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) period_universe = _periodAll_from_source(source, active_solve, workdir=workdir, provider=provider) period_invest = _expand_invest_branch_periods( diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 3b89091a2..62c9efdb2 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -155,14 +155,17 @@ def _recourse_invest_active( # before any producer runs, and producers run nowhere else. _RECOURSE_ANCHOR_PAIRS: "pl.DataFrame | None" = None -# Slice D α-1 — boundary-scoped Provider for the walker's canonical year/ -# factor arms. Set (to ``provider`` under recourse, else ``None``) at each -# Layer-4 boundary alongside the anchor pairs, so ``period_walk_iterator`` -# revives the canonical ``p_years_d.csv`` / ``p_years_represented.csv`` arms -# (correct branch-period years) WITHOUT threading ``provider`` through the -# ~12 NPV/edd walker-caller signatures. ``None`` (flag-off / deterministic) -# → arms dead → today's fill_null(0.0) walker behaviour → the W6 pin. +# Slice D α-1 — boundary-scoped Provider (+ its workdir, which the +# canonical readers need to key the Provider path) for the walker's year/ +# factor arms. Set (under recourse, else ``None``) at each Layer-4 +# boundary alongside the anchor pairs, so ``period_walk_iterator`` revives +# the canonical ``p_years_d.csv`` / ``p_years_represented.csv`` arms +# (correct branch-period years) WITHOUT threading ``provider``/``workdir`` +# through the ~12 NPV/edd walker-caller signatures. ``None`` (flag-off / +# deterministic) → arms dead → today's fill_null(0.0) walker behaviour → +# the W6 pin. _RECOURSE_WALK_PROVIDER: "object | None" = None +_RECOURSE_WALK_WORKDIR: "Path | None" = None def _recourse_walk_provider() -> "object | None": @@ -170,6 +173,40 @@ def _recourse_walk_provider() -> "object | None": return _RECOURSE_WALK_PROVIDER +def _recourse_walk_workdir() -> "Path | None": + """The active boundary's recourse walker workdir (α-1), or ``None``.""" + return _RECOURSE_WALK_WORKDIR + + +def _recourse_branch_axis_present() -> bool: + """True iff the active boundary is recourse-active with a genuine branch + invest axis — i.e. the anchor-pairs holder was armed non-empty. The + capability conjunct (§2), computed from the provider (branch-cluster + frames on ``flex_data`` are not populated until derived_g).""" + return _RECOURSE_ANCHOR_PAIRS is not None + + +def _recourse_usable_ctx(ctx: "object | None") -> "object | None": + """Return *ctx* iff its ``period_branch`` frame is usable for branch + expansion (≥1 row with a NON-NULL anchor), else ``None``. + + On the snapshot-reload path (``load_flextool`` off a dumped workdir) + the SolveContext's ``period_branch`` can carry all-null ``d_anchor`` + values; the ctx-first arms would then silently produce an empty + anchor map (no fan) instead of falling through to the provider CSV + (which is correct on that path). Sanitizing here keeps the recourse + helpers robust without touching the shared dispatch-side helper. + """ + if ctx is None: + return None + pb_ctx = ctx.period_branch + if pb_ctx.height == 0: + return ctx # empty is a legitimate "fall through" signal already + if pb_ctx["d_anchor"].null_count() == pb_ctx.height: + return None + return ctx + + def _build_recourse_anchor_pairs( workdir: Path | None, *, @@ -186,6 +223,7 @@ def _build_recourse_anchor_pairs( """ if not _recourse_invest_active(workdir, ctx=ctx, provider=provider): return None + ctx = _recourse_usable_ctx(ctx) pb_df: "pl.DataFrame | None" = None in_use: set[str] = set() if ctx is not None: @@ -234,9 +272,12 @@ def _enter_recourse_anchor_scope( ``None`` when flag-off) so no stale value from a prior solve can leak. Also arms the boundary-scoped walker Provider (α-1). """ - global _RECOURSE_ANCHOR_PAIRS, _RECOURSE_WALK_PROVIDER + global _RECOURSE_ANCHOR_PAIRS, _RECOURSE_WALK_PROVIDER, \ + _RECOURSE_WALK_WORKDIR active = _recourse_invest_active(workdir, ctx=ctx, provider=provider) _RECOURSE_WALK_PROVIDER = provider if active else None + _RECOURSE_WALK_WORKDIR = ( + Path(workdir) if active and workdir is not None else None) _RECOURSE_ANCHOR_PAIRS = ( _build_recourse_anchor_pairs(workdir, ctx=ctx, provider=provider) if active else None) @@ -5513,20 +5554,27 @@ def apply_derived_c( # divest set predicates + cap readers built below (no-op flag-off). _enter_recourse_anchor_scope(workdir, ctx=ctx, provider=provider) # Slice D §8 (E) — the scenario-lineage frame for the edd builders. - # Active only under recourse AND a genuine branch invest axis - # (capability conjunct §2). Hoist the precondition check ABOVE the - # edd try/except blocks (§6) so a LineageFilterError is loud, not - # swallowed. ``None`` flag-off → the edd builders keep today's - # unfiltered (all-pairs) behaviour → byte-parity. - _c_lineage = ( - getattr(flex_data, "dd_same_scenario", None) - if flex_data.recourse_invest and _has_branch_invest_axis(flex_data) - else None) - if _c_lineage is not None: + # Active only under recourse AND a genuine branch invest axis — the + # anchor-pairs holder just armed above is exactly that conjunct + # (non-None ⟺ recourse-active AND synthetic branches in PIU). Build + # the lineage frame ON-DEMAND from the provider (``dd_same_scenario_df`` + # is a standalone builder): ``flex_data.dd_same_scenario`` is not + # populated until ``apply_branch_cluster`` in derived_g, which runs + # AFTER this boundary. Hoist the precondition check ABOVE the edd + # try/except blocks (§6) so a LineageFilterError is loud, not swallowed. + # ``None`` flag-off → edd builders keep today's unfiltered behaviour. + if _RECOURSE_ANCHOR_PAIRS is not None: from flextool.engine_polars._derived_branch import ( assert_recourse_npv_preconditions as _assert_recourse, + dd_same_scenario_df as _dd_same_scenario_df, ) - _assert_recourse(None, source, active_solve, ctx=ctx, provider=provider) + _lin_ctx = _recourse_usable_ctx(ctx) + _c_lineage = _dd_same_scenario_df( + workdir, source, active_solve, ctx=_lin_ctx, provider=provider) + _assert_recourse(workdir, source, active_solve, + ctx=_lin_ctx, provider=provider) + else: + _c_lineage = None # Δ.12b — assignment is unconditional except for fields with # documented helper-coverage gaps (multi-year cascade extends @@ -6372,8 +6420,11 @@ def _expand_invest_branch_periods(period_invest: "list[str] | None", return period_invest if not _recourse_invest_active(workdir, ctx=ctx, provider=provider): return period_invest + # Sanitize the ctx (snapshot-reload all-null anchors → fall through + # to the provider CSV arm inside the dispatch helper). return _expand_branch_periods(list(period_invest), workdir, - ctx=ctx, provider=provider) + ctx=_recourse_usable_ctx(ctx), + provider=provider) def _dt_period_active_steps_from_workdir( @@ -10144,16 +10195,18 @@ def apply_synthetic_invest_sets(flex_data: object, period_with_history = (_read_period_with_history(workdir, provider=provider) or list(period_in_use)) # Slice D §8 — synthetic edd lineage filter (gated + hoisted check). - _s_lineage = ( - getattr(flex_data, "dd_same_scenario", None) - if _recourse_invest_active(workdir, provider=provider) - and _has_branch_invest_axis(flex_data) - else None) - if _s_lineage is not None: + # Build on-demand from the provider (branch-cluster runs later); the + # anchor-pairs holder armed at the top of this fn is the conjunct. + if _RECOURSE_ANCHOR_PAIRS is not None: from flextool.engine_polars._derived_branch import ( assert_recourse_npv_preconditions as _assert_recourse, + dd_same_scenario_df as _dd_same_scenario_df, ) - _assert_recourse(None, source, active_solve, provider=provider) + _s_lineage = _dd_same_scenario_df( + workdir, source, active_solve, provider=provider) + _assert_recourse(workdir, source, active_solve, provider=provider) + else: + _s_lineage = None try: edd_inv = _edd_invest_lf( source, active_solve, ed_inv.lazy(), diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index acf3417e7..c0762180d 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -261,14 +261,19 @@ def period_walk_iterator( triple). """ from flextool.engine_polars._axis_enums import empty_like - # Slice D α-1: fall back to the boundary-scoped recourse Provider so the - # canonical year/factor arms revive under the flag without threading - # ``provider`` through every NPV/edd walker caller. ``None`` off-flag. + # Slice D α-1: fall back to the boundary-scoped recourse Provider (and + # its workdir — the canonical year readers key the Provider path off + # it) so the year/factor arms revive under the flag without threading + # ``provider``/``workdir`` through every NPV/edd walker caller. + # Off-flag both are ``None`` → arms dead → byte-parity. if provider is None: from flextool.engine_polars._derived_params import ( _recourse_walk_provider, + _recourse_walk_workdir, ) provider = _recourse_walk_provider() + if provider is not None and workdir is None: + workdir = _recourse_walk_workdir() if not period_in_use: if factor_side is None: return empty_like(ed_lf, ["e", "d"], diff --git a/flextool/engine_polars/_orchestration.py b/flextool/engine_polars/_orchestration.py index d7e763643..a38a6e80b 100644 --- a/flextool/engine_polars/_orchestration.py +++ b/flextool/engine_polars/_orchestration.py @@ -1563,15 +1563,28 @@ def _write_scale_csv( # --------------------------------------------------------------------------- -def _stochastic_invest_guard_message(solve_name: str, mode: str) -> str: - """Slice C Guard 1 message: recourse stochastic invest not yet available.""" - return ( - f"Solve '{solve_name}' sets stochastic_invest_method='{mode}', but " - f"recourse stochastic investment is not yet available; it requires " - f"the branch-period invest capability (branch periods on the invest " - f"axis — recourse plan Slice D). Set stochastic_invest_method=none " - f"(the default) to run this solve." - ) +def _stochastic_invest_raw_guard(solve_cfg, base_name: str) -> None: + """Slice D Guard 1 — validate the RAW authored stochastic_invest_method. + + ``'recourse'`` is now ACCEPTED (per-scenario stochastic investment + landed in Slice D). The guard reads the RAW authored value rather than + the resolver's output: :meth:`SolveConfig.stochastic_invest_method_for` + silently collapses any UNrecognised value to ``'none'`` (a typo like + ``'recuorse'`` would then run deterministic), so an authoring-time check + must inspect the raw dict and reject anything that is neither blank / + ``'none'`` nor ``'recourse'``. Blank / absent tolerate-as-none (matches + the resolver). No-op when the solve authors nothing. + """ + raw = solve_cfg.stochastic_invest_method.get(base_name) + if raw is None: + return + val = str(raw).strip().lower() + if val not in ("", "none", "recourse"): + raise FlexToolConfigError( + f"Solve '{base_name}' sets stochastic_invest_method='{raw}' — " + f"unrecognised value. Supported: 'none' (default), 'recourse' " + f"(per-scenario stochastic investment)." + ) def _validate_model_solve(state: RunnerState) -> list[str]: @@ -1592,17 +1605,15 @@ def _validate_model_solve(state: RunnerState) -> list[str]: solves = next(iter(state.solve.model_solve.values())) if not solves: raise FlexToolConfigError("No solves in model.") - # Slice C Guard 1 (fail-fast pass): reject stochastic_invest_method - # != none for the model's top-level solves before any solve runs. - # This only sees the top-level ``solves`` array (not solves reached - # via ``contains_solves`` recursion), so it is a convenience — the - # authoritative per-solve guard lives at the top of ``run()``. + # Slice D Guard 1 (fail-fast pass): validate the RAW authored + # stochastic_invest_method for the model's top-level solves before any + # solve runs — 'recourse' is accepted; only unrecognised authored + # values raise. This only sees the top-level ``solves`` array (not + # solves reached via ``contains_solves`` recursion), so it is a + # convenience — the authoritative per-solve guard lives at the top of + # ``run()``. for _s in solves: - _mode = state.solve.stochastic_invest_method_for(_s) - if _mode != "none": - raise FlexToolConfigError( - _stochastic_invest_guard_message(_s, _mode) - ) + _stochastic_invest_raw_guard(state.solve, _s) return solves @@ -2374,18 +2385,15 @@ def _on_subsolve(entry: dict) -> None: def run(self, complete_solve_name: str) -> int: _phase_prof("run_enter") - # Slice C Guard 1 (authoritative): recourse stochastic - # investment is opt-in but not yet implemented (Slice D). - # Reject flag-on for any solve that RUNS — including each roll - # of a rolling solve and each contained/chained solve — before - # building its FlexData/LP. The roll-suffix strip mirrors the + # Slice D Guard 1 (authoritative): validate the RAW authored + # stochastic_invest_method for any solve that RUNS — including + # each roll of a rolling solve and each contained/chained solve + # — before building its FlexData/LP. 'recourse' is accepted + # (per-scenario stochastic investment); only unrecognised + # authored values raise. The roll-suffix strip mirrors the # base-name derivation below (``:2486``). _guard_base = re.sub(r"_roll_\d+$", "", complete_solve_name) - _si_mode = state.solve.stochastic_invest_method_for(_guard_base) - if _si_mode != "none": - raise FlexToolConfigError( - _stochastic_invest_guard_message(_guard_base, _si_mode) - ) + _stochastic_invest_raw_guard(state.solve, _guard_base) # Cross-level eviction — release any EXHAUSTED prior solve-level's # live HiGHS instance + flex_data_provider BEFORE this solve builds # its FlexData/LP, so two level footprints never coexist (the DES diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index f287f4ed4..70b55b40f 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -4147,19 +4147,35 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: * d.p_storage_state_reference_price * ref_price_factor) # Invest / divest objective contributions. - # NOTE: per .mod:2116-2119 — investment / divestment objective terms - # are explicitly NOT weighted by pd_branch_weight under the current - # stochastics regime. The .mod author's note: # - # "Currently investment happens only on the realized branch and - # the rest get them as existing. Only one period investment is - # supported with stochastics. The branch weight should be added - # if this is changed." - # - # Preprocessing excludes branch periods from ``period_invest`` so - # ``v_invest[e, d]`` only exists on realised periods — the weight - # would be 1.0 anyway. No multiplier needed. Recourse-investment is - # explicitly out of scope (see audit/a6_b_dim_alternative.md). + # Slice D — per-scenario (wait-and-see) stochastic investment. Under + # ``recourse`` the invest axis is fanned, so every scenario branch + # (realised included) carries its OWN ``v_invest[e, d]`` columns at and + # after the branching period; each is probability-weighted by + # ``pd_branch_weight[d]`` so the objective is the expected value over + # scenario leaves. Under ``none`` (default) the invest axis is + # realised-only, the weight would be 1.0, and the multiply is skipped + # for byte-parity. (Replaces the .mod:2116-2119 "NOT weighted / the + # branch weight should be added if this is changed" note — this is that + # change.) ALL EIGHT invest/divest annuity/fixed-cost Params are + # weighted: process-side annu/lf/lfd/annd AND node-side + # annu_n/lf_n/lfd_n/annd_n (else storage/node invest stays unweighted). + _recourse_wt = (getattr(d, "recourse_invest", False) + and d.pd_branch_weight is not None) + + def _wt(param): + """Multiply a ``(x, d)`` cost Param by ``pd_branch_weight[d]`` + (Slice D). No-op when the param is None or the flag is off.""" + if param is None or not _recourse_wt: + return param + f = (param.frame + .join(d.pd_branch_weight.frame.rename({"value": "w"}), + on="d", how="left") + .with_columns(value=pl.col("value") + * pl.col("w").fill_null(1.0)) + .drop("w")) + return Param(param.dims, f) + if has_invest_p: annu = Param(("p", "d"), d.ed_entity_annual_discounted.frame.pipe(rename_to_axis, {"e": "p"})) \ @@ -4168,9 +4184,9 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: d.ed_lifetime_fixed_cost.frame.pipe(rename_to_axis, {"e": "p"})) \ if d.ed_lifetime_fixed_cost is not None else None if annu is not None: - obj = obj + Sum(v_invest_p * d.p_unitsize * annu) + obj = obj + Sum(v_invest_p * d.p_unitsize * _wt(annu)) if lf is not None: - obj = obj + Sum(v_invest_p * d.p_unitsize * lf) + obj = obj + Sum(v_invest_p * d.p_unitsize * _wt(lf)) if has_divest_p: lfd = Param(("p", "d"), d.ed_lifetime_fixed_cost_divest.frame.pipe(rename_to_axis, {"e": "p"})) \ @@ -4179,9 +4195,9 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: d.ed_entity_annual_divest_discounted.frame.pipe(rename_to_axis, {"e": "p"})) \ if d.ed_entity_annual_divest_discounted is not None else None if lfd is not None: - obj = obj - Sum(v_divest_p * d.p_unitsize * lfd) + obj = obj - Sum(v_divest_p * d.p_unitsize * _wt(lfd)) if annd is not None: - obj = obj - Sum(v_divest_p * d.p_unitsize * annd) + obj = obj - Sum(v_divest_p * d.p_unitsize * _wt(annd)) if has_invest_n: us_n = Param(("n",), d.p_state_unitsize.frame) annu_n = Param(("n", "d"), @@ -4191,9 +4207,9 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: d.ed_lifetime_fixed_cost.frame.pipe(rename_to_axis, {"e": "n"})) \ if d.ed_lifetime_fixed_cost is not None else None if annu_n is not None: - obj = obj + Sum(v_invest_n * us_n * annu_n) + obj = obj + Sum(v_invest_n * us_n * _wt(annu_n)) if lf_n is not None: - obj = obj + Sum(v_invest_n * us_n * lf_n) + obj = obj + Sum(v_invest_n * us_n * _wt(lf_n)) if has_divest_n: us_n = Param(("n",), d.p_state_unitsize.frame) lfd_n = Param(("n", "d"), @@ -4203,9 +4219,9 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: d.ed_entity_annual_divest_discounted.frame.pipe(rename_to_axis, {"e": "n"})) \ if d.ed_entity_annual_divest_discounted is not None else None if lfd_n is not None: - obj = obj - Sum(v_divest_n * us_n * lfd_n) + obj = obj - Sum(v_divest_n * us_n * _wt(lfd_n)) if annd_n is not None: - obj = obj - Sum(v_divest_n * us_n * annd_n) + obj = obj - Sum(v_divest_n * us_n * _wt(annd_n)) # ─── §8.1 Existing-entity fixed cost (constant term) — opt-in ───────── # mod:2107-2115: diff --git a/tests/conftest.py b/tests/conftest.py index 1b03e00ab..2934b9dc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -490,6 +490,27 @@ def stoch_two_period_db_url(tmp_path_factory: pytest.TempPathFactory) -> str: return url +@pytest.fixture(scope="session") +def stoch_two_period_invest_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_invest`` fixture DB — Slice D per-scenario + (wait-and-see) stochastic-investment gate (design + ``specs/sliceD_recourse_invest_design.md`` §15.1). + + Built from ``tests/fixtures/stoch_two_period_invest.json`` (generated + by ``tests/fixtures/build_stoch_two_period_invest.py``). Defensive + migration as in :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stpi") + / "stoch_two_period_invest.sqlite") + url = json_to_db(FIXTURES_DIR / "stoch_two_period_invest.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -540,6 +561,7 @@ def case14_db_url(tmp_path_factory: pytest.TempPathFactory) -> str: "h2_trade_parity": "h2_trade_parity_db_url", "stochastics_pbt_inflow": "stochastics_pbt_inflow_db_url", "stoch_two_period": "stoch_two_period_db_url", + "stoch_two_period_invest": "stoch_two_period_invest_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -582,6 +604,7 @@ def scenario_workdir( h2_trade_parity_db_url, stochastics_pbt_inflow_db_url, stoch_two_period_db_url, + stoch_two_period_invest_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -637,6 +660,7 @@ def test_foo(scenario_workdir): "h2_trade_parity": h2_trade_parity_db_url, "stochastics_pbt_inflow": stochastics_pbt_inflow_db_url, "stoch_two_period": stoch_two_period_db_url, + "stoch_two_period_invest": stoch_two_period_invest_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/engine_polars/test_recourse_guards.py b/tests/engine_polars/test_recourse_guards.py index 4fb64d77e..d5489a8bb 100644 --- a/tests/engine_polars/test_recourse_guards.py +++ b/tests/engine_polars/test_recourse_guards.py @@ -86,17 +86,42 @@ def test_g1_resolver_normalisation(): assert sc.stochastic_invest_method_for("absent") == "none" # default -def test_g1_validate_model_solve_rejects_flag_on(): +def test_g1_validate_model_solve_accepts_recourse(): + """Slice D: Guard 1 is LIFTED for 'recourse' — the fail-fast pass no + longer raises on a recourse solve and returns the solve list.""" from flextool.engine_polars._orchestration import _validate_model_solve - sc = _solve_config_with_flag({"m": ["s_ok", "s_bad"]}, - {"s_bad": "recourse"}) + sc = _solve_config_with_flag({"m": ["s_ok", "s_rec"]}, + {"s_rec": "recourse"}) state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) - with pytest.raises(FlexToolConfigError) as exc: - _validate_model_solve(state) - msg = str(exc.value) - assert "s_bad" in msg - assert "branch-period invest capability" in msg + assert _validate_model_solve(state) == ["s_ok", "s_rec"] + + +def test_g1_raw_value_typo_rejected(): + """Slice D §12/F4: the guard inspects the RAW authored value (the + resolver would silently collapse a typo to 'none'), so an unrecognised + authored value raises — while 'recourse' / blank / absent do not.""" + from flextool.engine_polars._orchestration import ( + _stochastic_invest_raw_guard, + _validate_model_solve, + ) + + for bad in ("recuorse", "expected_value"): + sc = _solve_config_with_flag({"m": ["s"]}, {"s": bad}) + with pytest.raises(FlexToolConfigError) as exc: + _stochastic_invest_raw_guard(sc, "s") + assert bad in str(exc.value) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + with pytest.raises(FlexToolConfigError): + _validate_model_solve(state) + + # Accepted / tolerated raw values → no raise. + for ok in ("recourse", "", "none", "RECOURSE"): + sc = _solve_config_with_flag({"m": ["s"]}, {"s": ok}) + _stochastic_invest_raw_guard(sc, "s") # no raise + # Absent → no raise. + _stochastic_invest_raw_guard( + _solve_config_with_flag({"m": ["s"]}, {}), "s") def test_g1_validate_model_solve_passes_flag_none(): diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py new file mode 100644 index 000000000..d7aa88b5d --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -0,0 +1,159 @@ +"""Slice D — Phase D5 gate: hand-calculated per-scenario invest objective. + +Design: ``specs/sliceD_recourse_invest_design.md`` §15.1 (variant A) / §18 D5. + +Fixture ``stoch_two_period_invest.json`` (built via +``build_stoch_two_period_invest.py``): two 3-step periods, branches +rlz w=0.25 / low w=0.75, deterministic demand 100/130 MW, an existing +wind unit whose branch-varying ``upper_limit`` profile (rlz 0.40/0.40, +low 0.20/0.10 through the ``group__unit`` stochastic group) leaves the +investable ``peaker`` (unitsize 1, invest_cost 1, discount_rate 0.05, +lifetime 1 → annuity 1050 exactly; unbounded-forward windows 2100/1050) +residual demand rlz 60/90, low 80/120. Under ``recourse`` the invest +axis fans, each leaf invests its own capacity, and the objective is the +probability-weighted expectation: + + 0.25·(60·2100 + 30·1050) + 0.75·(80·2100 + 40·1050) = 196 875. + +Verified before pinning (design §15.1 verify-before-pin): +``ed_entity_annual_discounted[peaker, ·]`` = 2100/2100/1050/1050 and the +LP optimum is (60, 80, 30, 40) — empirically confirmed on the cascade +before these asserts were written. +""" +from __future__ import annotations + +import os + +import polars as pl +import pytest + +OBJECTIVE = 196_875.0 +SOLVE = "stoch_2p_inv" + +# ed_entity_annual_discounted[peaker, d] (§15.1 step 2). +EXPECTED_ANNU = { + "p2035": 2100.0, "p2040": 1050.0, + "p2035_low": 2100.0, "p2040_low": 1050.0, +} +EXPECTED_WEIGHT = { + "p2035": 0.25, "p2040": 0.25, "p2035_low": 0.75, "p2040_low": 0.75, +} +# Objective coefficient of v_invest[peaker, d] = unitsize·annu[d]·weight[d] +# (§15.2 D4): 525 / 262.5 / 1575 / 787.5. +EXPECTED_COEFF = {d: EXPECTED_ANNU[d] * EXPECTED_WEIGHT[d] + for d in EXPECTED_ANNU} +# Per-leaf optimal invest (§15.1 step 3). +EXPECTED_INVEST = { + "p2035": 60.0, "p2040": 30.0, "p2035_low": 80.0, "p2040_low": 40.0, +} + + +@pytest.fixture(scope="module") +def recourse_wf(scenario_workdir): + """Cascade run of the recourse fixture under STRICT autoscale (a + missing registry row would be loud — CLAUDE.md invariant 1).""" + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("recourse", + db_fixture="stoch_two_period_invest") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def _resolved(recourse_wf): + """In-process re-solve off the snapshot workdir: flex_data + solution + (gives the BRANCH v_invest columns, which the committed parquet + correctly omits per design §11.1).""" + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(recourse_wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, sol + + +# --------------------------------------------------------------------------- +# The D5 gate — hand-calc objective (§15.1) +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_recourse_objective_hand_calc(recourse_wf): + obj = float( + pl.read_parquet(recourse_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(OBJECTIVE, rel=1e-9) + + +@pytest.mark.solver +def test_recourse_per_leaf_invest(_resolved): + """§15.2 D2: v_invest carries all four (peaker, d) columns; each leaf + invests its own §15.1 quantities (60/30 realized, 80/40 low).""" + _data, sol = _resolved + vi = sol.value("v_invest_p") + got = {r["d"]: float(r["value"]) for r in vi.iter_rows(named=True)} + assert got == pytest.approx(EXPECTED_INVEST) + + +@pytest.mark.solver +def test_recourse_objective_coefficients(_resolved): + """§15.2 D4: the objective coefficient of each v_invest[peaker, d] is + unitsize·annu[d]·pd_branch_weight[d] = 525 / 262.5 / 1575 / 787.5. + Asserted on the model inputs (annu, weight, unitsize=1) — with the + §15.1 objective equality pinning the LP-level product end-to-end.""" + data, _sol = _resolved + annu = {str(r["e"]): None for r in []} # noqa: F841 — clarity below + annu = {str(r["d"]): float(r["value"]) + for r in data.ed_entity_annual_discounted.frame + .iter_rows(named=True)} + assert annu == pytest.approx(EXPECTED_ANNU) + wts = {str(r["d"]): float(r["value"]) + for r in data.pd_branch_weight.frame.iter_rows(named=True)} + for d, w in EXPECTED_WEIGHT.items(): + assert wts[d] == pytest.approx(w, abs=1e-12) + coeff = {d: annu[d] * wts[d] for d in annu} + assert coeff == pytest.approx(EXPECTED_COEFF) + assert data.recourse_invest is True + + +@pytest.mark.solver +def test_recourse_edd_lineage_filtered(_resolved): + """§15.2 D3 (the resolved Slice B W6 witness): edd_invest_set pairs + only same-leaf (d_invest, d); the cross-leaf pairs are ABSENT.""" + data, _sol = _resolved + rows = {(str(r["d_invest"]), str(r["d"])) + for r in data.edd_invest_set.iter_rows(named=True)} + assert rows == { + ("p2035", "p2035"), ("p2035", "p2040"), + ("p2035_low", "p2035_low"), ("p2035_low", "p2040_low"), + ("p2040", "p2040"), ("p2040_low", "p2040_low"), + } + + +@pytest.mark.solver +def test_recourse_committed_output_realized_only(recourse_wf): + """§11.1: the committed v_invest output carries ONLY the realized + periods (real names) — branch rows are horizon-output-only (D8).""" + inv = pl.read_parquet( + recourse_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") + got = {r["period"]: round(float(r["peaker"]), 6) + for r in inv.iter_rows(named=True)} + assert got == {"p2035": 60.0, "p2040": 30.0} + + +@pytest.mark.solver +def test_recourse_no_invest_na_constraints(_resolved): + """§13 (K): the fan starts at the solve's first step, so + pd_non_anticipativity is empty — no invest-NA constraint exists.""" + data, _sol = _resolved + pdna = data.pd_non_anticipativity + assert pdna is None or pdna.height == 0 diff --git a/tests/fixtures/build_stoch_two_period_invest.py b/tests/fixtures/build_stoch_two_period_invest.py new file mode 100644 index 000000000..d6024a0bd --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_invest.py @@ -0,0 +1,241 @@ +"""Build ``tests/fixtures/stoch_two_period_invest.json`` — the Slice D +per-scenario (wait-and-see) stochastic-investment gate fixture +(design ``specs/sliceD_recourse_invest_design.md`` §15.1, variant A). + +Extends the ``stoch_two_period`` shape (two 3-step periods p2035 / p2040, +branches ``rlz`` w=1.0 / ``low`` w=3.0 declared at p2035/t0001 so p2040 is +a CONTINUATION period) with an INVEST entity. The branch variation rides +a UNIT profile through ``group__unit`` (the base fixture's mechanism) — no +NODE enters the stochastic group, so the node-side NA net-charge pinning +(``model.py`` ``non_anticipativity_storage_use``, gated on ``group_node`` +× ``groupStochastic``) never fires and the LP stays separable per leaf +(hand-calc-able). A node-authored branch-keyed ``inflow`` would NOT work +without the node in the group — the stochastic inflow fold-in +(``_emit_period_params``) gates on ``group__node`` × include_stochastics — +which is why the demand variation is supply-side here: + + * node ``elec`` — balance, high penalty (VOLL), DETERMINISTIC demand + 100 MW (p2035) / 130 MW (p2040). + * unit ``wind`` → ``elec`` — existing 100 MW, cost 0, ``upper_limit`` + profile branch-varying via the stochastic group: rlz 0.40/0.40, + low 0.20/0.10 → wind supply rlz 40/40, low 20/10 → peaker residual + rlz 60/90, low 80/120 (the §15.1 demand table). + * unit ``peaker`` → ``elec`` — variable cost 0, virtual_unitsize 1 MW, + invest_method=invest_total, invest_cost 1 CUR/kW, discount_rate 0.05, + lifetime 1 (→ annuity 1000·CRF(0.05,1) = 1050 exactly), + invest_max_total 250 (non-binding at D5; the design's per-path + demonstration value 150 belongs to D6 per-path caps — the legacy + all-``d`` sum 210 would spuriously bind it before per-path caps exist). + +Hand calculation (design §15.1): with the invest axis fanned, per-scenario +optimal invest {p2035:60, p2040:30, p2035_low:80, p2040_low:40}; annuity +windows 2100 (branching) / 1050 (continuation); weighted objective + + 0.25·(60·2100 + 30·1050) + 0.75·(80·2100 + 40·1050) = 196 875. + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_two_period_invest.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 7)] +P1_STEPS = STEPS[:3] +P2_STEPS = STEPS[3:] + + +def _pack(value: object, value_type: str) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + src = json.loads((FIXTURES / "stochastics.json").read_text()) + + spec: dict = { + key: src[key] + for key in ( + "entity_classes", + "parameter_value_lists", + "parameter_groups", + "parameter_definitions", + "parameter_types", + ) + } + + # ``stochastics.json`` still carries the pre-v70 schema, so the + # ``solve.stochastic_invest_method`` opt-in (Slice C, v70) is not yet + # defined — but ``json_to_db`` validates authored values against the + # embedded schema BEFORE ``migrate_database`` runs. Inject the v70 + # parameter definition + value list here so the ``recourse`` value + # imports cleanly; the migration's idempotent ``add_update_item`` then + # reconciles it to the canonical v70 shape. + if not any(vl[0] == "stochastic_invest_methods" + for vl in spec["parameter_value_lists"]): + spec["parameter_value_lists"] += [ + ["stochastic_invest_methods", _pack("none", "str")], + ["stochastic_invest_methods", _pack("recourse", "str")], + ] + if not any(pd[0] == "solve" and pd[1] == "stochastic_invest_method" + for pd in spec["parameter_definitions"]): + spec["parameter_definitions"].append([ + "solve", "stochastic_invest_method", + _pack("none", "str"), "stochastic_invest_methods", + "How stochastic branches participate in investment " + "(none/recourse).", "solve_advanced", + ]) + + spec["alternatives"] = [ + ["init", "Model, solve and time structure"], + ["base", "System data (peaker invest + wind + elec demand)"], + ["stoch", "Stochastic branches + branch-varying wind profile"], + ["recourse", "stochastic_invest_method = recourse"], + ] + spec["scenarios"] = [ + ["recourse", False, "Per-scenario stochastic investment (flag on)"], + ] + spec["scenario_alternatives"] = [ + ["recourse", "init", "base"], + ["recourse", "base", "stoch"], + ["recourse", "stoch", "recourse"], + ["recourse", "recourse", None], + ] + + spec["entities"] = [ + ["group", "stoch_g", None], + ["model", "flextool", None], + ["node", "elec", None], + ["profile", "wprof", None], + ["solve", "stoch_2p_inv", None], + ["timeline", "y6h", None], + ["timeset", "ts1", None], + ["timeset", "ts2", None], + ["unit", "peaker", None], + ["unit", "wind", None], + ["group__unit", ["stoch_g", "wind"], None], + ["unit__outputNode", ["peaker", "elec"], None], + ["unit__outputNode", ["wind", "elec"], None], + ["unit__node__profile", ["wind", "elec", "wprof"], None], + ] + spec["entity_alternatives"] = [ + ["node", ["elec"], "init", True], + ["unit", ["peaker"], "init", True], + ["unit", ["wind"], "init", True], + ] + + # ---- init: model / solve / time structure -------------------------- + stochastic_branches = _map([ + ("p2035", _nested([ + ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 3.0)]))])), + ])), + ]) + # Deterministic profile (base alternative) — matches the rlz branch. + profile_base = _map( + [(t, 0.40) for t in P1_STEPS] + [(t, 0.40) for t in P2_STEPS] + ) + # Branch-varying wind profile (stoch alternative): wind supply + # rlz 40/40 MW, low 20/10 MW → peaker residual rlz 60/90, low 80/120. + profile_stoch = _map([ + ("rlz", _nested([ + ("t0001", _nested([(t, 0.40) for t in P1_STEPS])), + ("t0004", _nested([(t, 0.40) for t in P2_STEPS])), + ])), + ("low", _nested([ + ("t0001", _nested([(t, 0.20) for t in P1_STEPS])), + ("t0004", _nested([(t, 0.10) for t in P2_STEPS])), + ])), + ]) + # Deterministic demand: 100 MW at p2035 steps, 130 MW at p2040 steps. + inflow = _map( + [(t, -100.0) for t in P1_STEPS] + [(t, -130.0) for t in P2_STEPS] + ) + + spec["parameter_values"] = [ + ["model", "flextool", "solves", + _pack({"value_type": "str", "data": ["stoch_2p_inv"]}, "array"), + "init"], + ["timeline", "y6h", "timestep_duration", + _pack(_map([(t, 1.0) for t in STEPS]), "map"), "init"], + ["timeset", "ts1", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts1", "timeset_duration", + _pack(_map([("t0001", 3.0)]), "map"), "init"], + ["timeset", "ts2", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts2", "timeset_duration", + _pack(_map([("t0004", 3.0)]), "map"), "init"], + ["solve", "stoch_2p_inv", "period_timeset", + _pack(_map([("p2035", "ts1"), ("p2040", "ts2")]), "map"), "init"], + ["solve", "stoch_2p_inv", "realized_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_inv", "invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_inv", "realized_invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_inv", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_2p_inv", "years_represented", + _pack(_map([("p2035", 1.0), ("p2040", 1.0)]), "map"), "init"], + # ---- base: system data ---------------------------------------- + ["node", "elec", "node_type", _pack("balance", "str"), "base"], + ["node", "elec", "penalty_up", _pack(10000.0, "float"), "base"], + ["node", "elec", "penalty_down", _pack(10000.0, "float"), "base"], + ["node", "elec", "inflow", _pack(inflow, "map"), "base"], + ["unit", "wind", "existing", _pack(100.0, "float"), "base"], + ["unit", "wind", "efficiency", _pack(1.0, "float"), "base"], + ["unit__node__profile", ["wind", "elec", "wprof"], "profile_method", + _pack("upper_limit", "str"), "base"], + ["profile", "wprof", "profile", _pack(profile_base, "map"), "base"], + ["unit", "peaker", "efficiency", _pack(1.0, "float"), "base"], + ["unit", "peaker", "existing", _pack(0.0, "float"), "base"], + ["unit", "peaker", "virtual_unitsize", _pack(1.0, "float"), "base"], + ["unit", "peaker", "invest_method", + _pack("invest_total", "str"), "base"], + ["unit", "peaker", "invest_cost", _pack(1.0, "float"), "base"], + ["unit", "peaker", "discount_rate", _pack(0.05, "float"), "base"], + ["unit", "peaker", "lifetime", _pack(1.0, "float"), "base"], + ["unit", "peaker", "invest_max_total", _pack(250.0, "float"), + "base"], + ["unit__outputNode", ["peaker", "elec"], "other_operational_cost", + _pack(0.0, "float"), "base"], + # ---- stoch: branches + branch-varying profile ------------------ + ["group", "stoch_g", "include_stochastics", _pack("yes", "str"), + "stoch"], + ["solve", "stoch_2p_inv", "stochastic_branches", + _pack(stochastic_branches, "map"), "stoch"], + ["profile", "wprof", "profile", _pack(profile_stoch, "map"), + "stoch"], + # ---- recourse: opt-in flag ------------------------------------- + ["solve", "stoch_2p_inv", "stochastic_invest_method", + _pack("recourse", "str"), "recourse"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_invest.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_invest.json b/tests/fixtures/stoch_two_period_invest.json new file mode 100644 index 000000000..5886bbcfe --- /dev/null +++ b/tests/fixtures/stoch_two_period_invest.json @@ -0,0 +1,6091 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "Njk=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment (none/recourse).", + "solve_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solve and time structure" + ], + [ + "base", + "System data (peaker invest + wind + elec demand)" + ], + [ + "stoch", + "Stochastic branches + branch-varying wind profile" + ], + [ + "recourse", + "stochastic_invest_method = recourse" + ] + ], + "scenarios": [ + [ + "recourse", + false, + "Per-scenario stochastic investment (flag on)" + ] + ], + "scenario_alternatives": [ + [ + "recourse", + "init", + "base" + ], + [ + "recourse", + "base", + "stoch" + ], + [ + "recourse", + "stoch", + "recourse" + ], + [ + "recourse", + "recourse", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_inv", + null + ], + [ + "timeline", + "y6h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "timeset", + "ts2", + null + ], + [ + "unit", + "peaker", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "peaker" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF8ycF9pbnYiXX0=", + "array" + ], + "init" + ], + [ + "timeline", + "y6h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl0sIFsicDIwNDAiLCAidHMyIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_2p_inv", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMS4wXV19", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTAwLjBdLCBbInQwMDAyIiwgLTEwMC4wXSwgWyJ0MDAwMyIsIC0xMDAuMF0sIFsidDAwMDQiLCAtMTMwLjBdLCBbInQwMDA1IiwgLTEzMC4wXSwgWyJ0MDAwNiIsIC0xMzAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF0sIFsidDAwMDQiLCAwLjRdLCBbInQwMDA1IiwgMC40XSwgWyJ0MDAwNiIsIDAuNF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "peaker", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_max_total", + [ + "MjUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + "other_operational_cost", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_inv", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuNF0sIFsidDAwMDIiLCAwLjRdLCBbInQwMDAzIiwgMC40XV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC40XSwgWyJ0MDAwNSIsIDAuNF0sIFsidDAwMDYiLCAwLjRdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjJdLCBbInQwMDAyIiwgMC4yXSwgWyJ0MDAwMyIsIDAuMl1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMV0sIFsidDAwMDUiLCAwLjFdLCBbInQwMDA2IiwgMC4xXV19XV19XV19", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_inv", + "stochastic_invest_method", + [ + "InJlY291cnNlIg==", + "str" + ], + "recourse" + ] + ] +} From b2d003952de4a6bd6d7afb2f962009ed87826684 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 09:21:13 +0300 Subject: [PATCH 14/87] feat(stochastic): D6 per-path caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-scenario-path total-capacity caps so a total cap applies once per scenario leaf under recourse, never cross-scenario double-counting (design sliceD §7 (G) / §14 (L)). - d_leaf (d, leaf) frame in _derived_branch.py: partitions period_in_use into scenario leaves (real anchors -> "__realized", synthetic members -> time-branch id); registered on FlexData, built in apply_branch_cluster. - model.py _emit_entity_total_cap: the four maxInvest/maxDivest entity total builders emit the legacy over=("d",) shape + name for a single leaf (deterministic / flag-off, byte-identical) and …_path indexed by (entity, leaf) for >1 leaf, masking the inner sum to each leaf. - _cumulative_invest.py _emit_group_total_divest_cap: the divest-side group total gains the same per-path treatment (max/<= cap only). - autoscale registry: maxInvest/maxDivest_entity_total_path(_n) (POWER/ ENERGY) + maxDivestGroup_entity_total_path (group_capacity resolver); test_registry_coverage dynamic-name pins. - Fixture invest_max_total tightened 250 -> 150 (§15.1): per-leaf totals 90/120 non-binding, legacy all-d sum 210 would have bound — the asymmetry proves per-path. Gate test asserts two leaf rows. Gate: objective 196,875 exact; STRICT autoscale clean; flag-off parity (derived_branch/period_walk lineage, continuation fanout, recourse guards, deterministic invest cumulative/total goldens) green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_cumulative_invest.py | 95 +++++++++++--- flextool/engine_polars/_derived_branch.py | 76 +++++++++++ .../engine_polars/autoscale/_layer2_types.py | 20 +++ flextool/engine_polars/input.py | 8 ++ flextool/engine_polars/model.py | 124 +++++++++++++----- .../autoscale/test_registry_coverage.py | 11 ++ .../test_recourse_invest_gate.py | 28 ++++ .../fixtures/build_stoch_two_period_invest.py | 11 +- tests/fixtures/stoch_two_period_invest.json | 2 +- 9 files changed, 320 insertions(+), 55 deletions(-) diff --git a/flextool/engine_polars/_cumulative_invest.py b/flextool/engine_polars/_cumulative_invest.py index e9868d14d..f314ca158 100644 --- a/flextool/engine_polars/_cumulative_invest.py +++ b/flextool/engine_polars/_cumulative_invest.py @@ -722,6 +722,57 @@ def _emit_group_invest_period(m, d, vars: dict, # --------------------------------------------------------------------------- # Group invest / divest — total scope +def _emit_group_total_divest_cap( + m, *, legacy_name: str, path_name: str, outer: pl.DataFrame, + ge: pl.DataFrame, axis: str, var, unitsize, cap_param, + sense: str, d_leaf, +) -> None: + """Per-path divest-group total cap (design §7.2 (G) item 4). + + The divest group total sums ``v_divest * unitsize`` over ALL ``d`` + (``edd_set`` is None on the divest side) grouped by ``g`` — under + recourse that cross-scenario double-counts one cap. When more than + one scenario leaf covers the divest periods (recourse, sense + ``"<="``), emit ``path_name`` indexed by ``(g, leaf)`` with the + inner sum masked to each leaf's periods. Single leaf — deterministic + / flag-off — or the ``">="`` floor keeps the legacy ``over=g`` shape + and NAME (byte-parity; the design registers only the ``max`` path + cap). + """ + dl = None + if (sense == "<=" and d_leaf is not None + and getattr(d_leaf, "height", 0) > 0): + inv_periods = var.frame.select("d").unique() + _d_dt = var.frame.schema["d"] + dlx = d_leaf + if dlx.schema["d"] != _d_dt: + dlx = dlx.with_columns(pl.col("d").cast(_d_dt, strict=False)) + dl = dlx.join(inv_periods, on="d", how="semi") + n_leaves = (dl["leaf"].n_unique() + if dl is not None and dl.height > 0 else 1) + if n_leaves <= 1: + m.add_cstr( + legacy_name, + over = outer, + sense = sense, + lhs_terms = {"divest_grp": Sum(Where(var * unitsize, ge), + over=(axis, "d"))}, + rhs_terms = {"cap": cap_param}, + ) + return + mask = ge.join(dl, how="cross") + over_path = outer.join(dl.select("leaf").unique().sort("leaf"), + how="cross") + m.add_cstr( + path_name, + over = over_path, + sense = sense, + lhs_terms = {"divest_grp": Sum(Where(var * unitsize, mask), + over=(axis, "d"))}, + rhs_terms = {"cap": cap_param}, + ) + + def _emit_group_invest_total(m, d, vars: dict, kind: str, sense: str) -> None: """Mod: @@ -852,16 +903,18 @@ def _emit_group_invest_total(m, d, vars: dict, pl.col("p").is_in(v_p.frame["p"].unique())) \ .filter(pl.col("g").is_in(g_set["g"].unique())) if ge_p.height > 0: - lhs = Sum( - Where(v_p * d.p_unitsize, ge_p), - over=("p", "d"), - ) - m.add_cstr( - f"{name}_p", - over = outer, - sense = sense, - lhs_terms = {"divest_grp": lhs}, - rhs_terms = {"cap": cap_param}, + _emit_group_total_divest_cap( + m, + legacy_name = f"{name}_p", + path_name = f"{name}_path_p", + outer = outer, + ge = ge_p, + axis = "p", + var = v_p, + unitsize = d.p_unitsize, + cap_param = cap_param, + sense = sense, + d_leaf = getattr(d, "d_leaf", None), ) # Node branch if v_n is not None and d.p_state_unitsize is not None: @@ -876,16 +929,18 @@ def _emit_group_invest_total(m, d, vars: dict, pl.col("n").is_in(v_n.frame["n"].unique())) \ .filter(pl.col("g").is_in(g_set["g"].unique())) if ge_n.height > 0: - lhs = Sum( - Where(v_n * us_n, ge_n), - over=("n", "d"), - ) - m.add_cstr( - f"{name}_n", - over = outer, - sense = sense, - lhs_terms = {"divest_grp": lhs}, - rhs_terms = {"cap": cap_param}, + _emit_group_total_divest_cap( + m, + legacy_name = f"{name}_n", + path_name = f"{name}_path_n", + outer = outer, + ge = ge_n, + axis = "n", + var = v_n, + unitsize = us_n, + cap_param = cap_param, + sense = sense, + d_leaf = getattr(d, "d_leaf", None), ) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 269c02670..ac8a5e15e 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -957,6 +957,75 @@ def dd_same_scenario_df( ctx=ctx, provider=provider).collect() +def d_leaf_lf( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.LazyFrame: + """``d_leaf`` — (d, leaf) scenario-leaf PARTITION of ``period_in_use``. + + Each in-use period maps to exactly ONE scenario leaf (design §7.2): + real-named anchors → the ``"__realized"`` leaf; synthetic fan + members → their time-branch id (``tb_of[m]``). Because the + continuation fan starts at the solve's first step (§K), leaves + PARTITION the invest axis — no period is on two leaves — so the map + is well-defined and the per-leaf invest sums are disjoint. + + Consumed by the per-path total caps + (``maxInvest/maxDivest_entity_total_path`` — ``model.py`` — and the + divest ``maxDivestGroup_entity_total_path`` — ``_cumulative_invest.py``): + the leaf column joins into each cap row's key so a total cap applies + once per scenario path, never cross-scenario double-counting. + + Deterministic / flag-off solves degenerate to a single + ``"__realized"`` leaf (``period__branch`` silent → every in-use + member is an anchor, mirroring :func:`_classify_lineage`'s + ``pb_rows == []`` branch), so the total caps stay on the legacy + single-row shape (byte-parity). ``leaf`` is a plain ``Utf8`` + column — it is NOT an axis vocabulary; it only keys constraint rows. + """ + schema = {"d": schema_dtype(_enums, "d"), "leaf": pl.Utf8} + pb_rows, piu, tb_of = _lineage_inputs( + workdir, source, active_solve, ctx=ctx, provider=provider) + if not piu: + return _empty_lf(schema) + anchors, _pos, synthetic, _anchor_of = _classify_lineage( + pb_rows, piu, tb_of) + rows: list[tuple[str, str]] = [(a, "__realized") for a in anchors] + rows += [(m, tb_of[m]) for m in synthetic] + raw = pl.DataFrame({"d": [r[0] for r in rows], + "leaf": [r[1] for r in rows]}, + schema={"d": pl.Utf8, "leaf": pl.Utf8}) + out = raw.select(cast_dim(pl.col("d"), _enums, "d"), pl.col("leaf")) + if out["d"].null_count(): + bad = sorted({v for v, c in zip(raw["d"].to_list(), + out["d"].to_list()) if c is None}) + raise ValueError( + f"d_leaf: axis-enum cast nulled period token(s) {bad} — the " + "token is missing from the live d-axis vocabulary (wiring " + "bug; the 4.0.4 vocabulary splice includes every continuation " + "branch-period token)." + ) + out = out.sort(pl.col("d").cast(pl.Utf8), "leaf") + return out.lazy() + + +def d_leaf_df( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.DataFrame: + """Collect wrapper for :func:`d_leaf_lf` — always a typed frame.""" + return d_leaf_lf(workdir, source, active_solve, + ctx=ctx, provider=provider).collect() + + def pd_non_anticipativity_lf( workdir: Path | None, source: "InputSource | None" = None, @@ -1373,6 +1442,11 @@ def apply_branch_cluster( workdir, source, active_solve, ctx=ctx, provider=provider) flex_data.pd_non_anticipativity = pd_non_anticipativity_df( workdir, source, active_solve, ctx=ctx, provider=provider) + # Slice D per-path total caps (design §7.2): the (d, leaf) partition + # that keeps entity/group total caps per-scenario-path. Single + # "__realized" leaf for deterministic solves (byte-parity). + flex_data.d_leaf = d_leaf_df( + workdir, source, active_solve, ctx=ctx, provider=provider) # 3-4. Branch-weight Params (lazy ports of the previous eager # helpers in ``_derived_params.py``). @@ -1426,6 +1500,8 @@ def apply_branch_cluster( "period_branch_full_lf", "dd_same_scenario_lf", "dd_same_scenario_df", + "d_leaf_lf", + "d_leaf_df", "pd_non_anticipativity_lf", "pd_non_anticipativity_df", "pd_branch_weight_param", diff --git a/flextool/engine_polars/autoscale/_layer2_types.py b/flextool/engine_polars/autoscale/_layer2_types.py index fc3f22c43..8a27a028d 100644 --- a/flextool/engine_polars/autoscale/_layer2_types.py +++ b/flextool/engine_polars/autoscale/_layer2_types.py @@ -279,6 +279,18 @@ class CstrFamily: # flextool/engine_polars/model.py:2457 "maxDivest_entity_total_n": CstrFamily(QuantityType.ENERGY), + # Slice D per-path total caps (design §7.2/§14) — one cap row per + # (entity, scenario-leaf). Emitted by + # ``_emit_entity_total_cap`` (model.py) when >1 scenario leaf exists + # (recourse); the leaf-suffixed sums replace the cross-scenario + # ``over=("d",)`` total. Registered EXACTLY (``_path`` is not a + # known ``lookup_cstr`` suffix, so a missing row silently reverts to + # an un-scaled LP under STRICT). + "maxInvest_entity_total_path": CstrFamily(QuantityType.POWER), + "maxInvest_entity_total_path_n": CstrFamily(QuantityType.ENERGY), + "maxDivest_entity_total_path": CstrFamily(QuantityType.POWER), + "maxDivest_entity_total_path_n": CstrFamily(QuantityType.ENERGY), + # min-invest / min-divest entity totals — group_capacity resolved # via _p / _n suffix in _cumulative_invest.py. # flextool/engine_polars/_cumulative_invest.py:456 (minInvest_entity_total_p) @@ -333,6 +345,14 @@ class CstrFamily: "minDivestGroup_entity_total": CstrFamily( None, member_class_resolver="group_capacity" ), + # Slice D per-path divest-group total cap (design §7.2 item 4 / + # §14): ``maxDivestGroup_entity_total_path_{p,n}`` emitted by + # ``_emit_group_total_divest_cap`` when >1 scenario leaf exists. The + # ``_p`` / ``_n`` member-class suffix resolves POWER vs ENERGY off + # this key (``lookup_cstr`` strips it since the resolver is set). + "maxDivestGroup_entity_total_path": CstrFamily( + None, member_class_resolver="group_capacity" + ), # flextool/engine_polars/_cumulative_invest.py:845 (maxInvestGroup_entity_cumulative_p) # flextool/engine_polars/_cumulative_invest.py:869 (maxInvestGroup_entity_cumulative_n) "maxInvestGroup_entity_cumulative": CstrFamily( diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 5d77eaf23..d38819d17 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -938,6 +938,14 @@ class FlexData: # points exist (Slice E). dd_same_scenario: pl.DataFrame | None = None # (d, d_other) pd_non_anticipativity: pl.DataFrame | None = None # (d, b) + # Slice D — per-scenario-leaf partition of period_in_use (design §7.2): + # (d, leaf) with real-named anchors → "__realized", synthetic fan + # members → their time-branch id. Drives the per-path total caps + # (maxInvest/maxDivest_entity_total_path, maxDivestGroup_entity_total_path) + # so a total cap applies once per scenario path, never cross-scenario. + # Single "__realized" leaf for deterministic / flag-off solves → + # caps stay on the legacy single-row shape (byte-parity). + d_leaf: pl.DataFrame | None = None # (d, leaf) # Slice D — per-scenario (wait-and-see) stochastic investment gate. # Set once in ``apply_derived_c`` from diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 70b55b40f..29de723e4 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -124,6 +124,60 @@ def _check(d, fields: tuple[str, ...], feature: str) -> None: ) +def _emit_entity_total_cap( + m, *, legacy_name: str, path_name: str, over_e: pl.DataFrame, + var, unitsize, cap_param, d_leaf, lhs_label: str, +) -> None: + """Per-path entity total cap (design §7.2 (G)). + + Sums ``var * unitsize`` over the invest axis's periods against a + per-entity total cap. With a single scenario leaf — deterministic + or flag-off, where ``d_leaf`` degenerates to one ``"__realized"`` + leaf — this emits the legacy ``over=("d",)`` total under the legacy + NAME, byte-identical to the pre-recourse constraint. With more than + one leaf (recourse), it emits ``path_name`` indexed by + ``(entity, leaf)`` with the inner ``d``-sum masked to each leaf's + own periods; the per-entity ``cap_param`` broadcasts across leaves + (one cap per scenario path — no cross-scenario double-count). + + ``leaf`` is a plain ``Utf8`` index column carried through the + ``Where`` mask exactly as ``g`` is in the group-total builders; + ``Sum(over=("d",))`` leaves ``(entity, leaf)`` matching ``over_e × + leaves``. + """ + dl = None + if d_leaf is not None and getattr(d_leaf, "height", 0) > 0: + inv_periods = var.frame.select("d").unique() + _d_dt = var.frame.schema["d"] + dlx = d_leaf + if dlx.schema["d"] != _d_dt: + dlx = dlx.with_columns(pl.col("d").cast(_d_dt, strict=False)) + dl = dlx.join(inv_periods, on="d", how="semi") + n_leaves = (dl["leaf"].n_unique() + if dl is not None and dl.height > 0 else 1) + if n_leaves <= 1: + m.add_cstr( + legacy_name, + over = over_e, + sense = "<=", + lhs_terms = {lhs_label: Sum(Where(var * unitsize, over_e), + over=("d",))}, + rhs_terms = {"cap": cap_param}, + ) + return + mask = over_e.join(dl, how="cross") + over_path = over_e.join(dl.select("leaf").unique().sort("leaf"), + how="cross") + m.add_cstr( + path_name, + over = over_path, + sense = "<=", + lhs_terms = {lhs_label: Sum(Where(var * unitsize, mask), + over=("d",))}, + rhs_terms = {"cap": cap_param}, + ) + + def _add_non_anticipativity_constraints( m, d, db_pairs: pl.DataFrame, *, v_state, v_online_integer, v_online_linear, v_reserve, @@ -3080,14 +3134,16 @@ def _adjust_cap_frame(cap_frame: pl.DataFrame, key_col: str, d.e_invest_max_total.frame.pipe(rename_to_axis, {"e": "p"}), "p", max_prior_cap) e_inv_max_p = Param(("p",), cap_frame) - m.add_cstr( - "maxInvest_entity_total", - over = e_inv_p, - sense = "<=", - lhs_terms = {"invest_total": - Sum(Where(v_invest_p * d.p_unitsize, e_inv_p), - over=("d",))}, - rhs_terms = {"cap": e_inv_max_p}, + _emit_entity_total_cap( + m, + legacy_name = "maxInvest_entity_total", + path_name = "maxInvest_entity_total_path", + over_e = e_inv_p, + var = v_invest_p, + unitsize = d.p_unitsize, + cap_param = e_inv_max_p, + d_leaf = getattr(d, "d_leaf", None), + lhs_label = "invest_total", ) if (has_divest_p and d.e_divest_total is not None and d.e_divest_total.height > 0 @@ -3105,14 +3161,16 @@ def _adjust_cap_frame(cap_frame: pl.DataFrame, key_col: str, d.e_divest_max_total.frame.pipe(rename_to_axis, {"e": "p"}), "p", prior_divested) e_div_max_p = Param(("p",), cap_frame) - m.add_cstr( - "maxDivest_entity_total", - over = e_div_p, - sense = "<=", - lhs_terms = {"divest_total": - Sum(Where(v_divest_p * d.p_unitsize, e_div_p), - over=("d",))}, - rhs_terms = {"cap": e_div_max_p}, + _emit_entity_total_cap( + m, + legacy_name = "maxDivest_entity_total", + path_name = "maxDivest_entity_total_path", + over_e = e_div_p, + var = v_divest_p, + unitsize = d.p_unitsize, + cap_param = e_div_max_p, + d_leaf = getattr(d, "d_leaf", None), + lhs_label = "divest_total", ) # Node analogues for per-entity totals. if (has_invest_n and d.e_invest_total is not None @@ -3132,13 +3190,16 @@ def _adjust_cap_frame(cap_frame: pl.DataFrame, key_col: str, "n", max_prior_cap) e_inv_max_n = Param(("n",), cap_frame) us_n = Param(("n",), d.p_state_unitsize.frame) - m.add_cstr( - "maxInvest_entity_total_n", - over = e_inv_n, - sense = "<=", - lhs_terms = {"invest_total": - Sum(Where(v_invest_n * us_n, e_inv_n), over=("d",))}, - rhs_terms = {"cap": e_inv_max_n}, + _emit_entity_total_cap( + m, + legacy_name = "maxInvest_entity_total_n", + path_name = "maxInvest_entity_total_path_n", + over_e = e_inv_n, + var = v_invest_n, + unitsize = us_n, + cap_param = e_inv_max_n, + d_leaf = getattr(d, "d_leaf", None), + lhs_label = "invest_total", ) if (has_divest_n and d.e_divest_total is not None and d.e_divest_total.height > 0 @@ -3157,13 +3218,16 @@ def _adjust_cap_frame(cap_frame: pl.DataFrame, key_col: str, "n", prior_divested) e_div_max_n = Param(("n",), cap_frame) us_n = Param(("n",), d.p_state_unitsize.frame) - m.add_cstr( - "maxDivest_entity_total_n", - over = e_div_n, - sense = "<=", - lhs_terms = {"divest_total": - Sum(Where(v_divest_n * us_n, e_div_n), over=("d",))}, - rhs_terms = {"cap": e_div_max_n}, + _emit_entity_total_cap( + m, + legacy_name = "maxDivest_entity_total_n", + path_name = "maxDivest_entity_total_path_n", + over_e = e_div_n, + var = v_divest_n, + unitsize = us_n, + cap_param = e_div_max_n, + d_leaf = getattr(d, "d_leaf", None), + lhs_label = "divest_total", ) _build_prof("before:conversion_indirect") diff --git a/tests/engine_polars/autoscale/test_registry_coverage.py b/tests/engine_polars/autoscale/test_registry_coverage.py index 8887a9e75..9bd6cdde5 100644 --- a/tests/engine_polars/autoscale/test_registry_coverage.py +++ b/tests/engine_polars/autoscale/test_registry_coverage.py @@ -217,6 +217,17 @@ def test_constraint_registry_covers_engine_sources() -> None: "minFlow_minload_linear", "minFlow_minload_integer", "minimum_uptime_linear", "minimum_uptime_integer", "minimum_downtime_linear", "minimum_downtime_integer", + # Slice D per-path total caps — model.py ``_emit_entity_total_cap`` + # and _cumulative_invest.py ``_emit_group_total_divest_cap`` pass the + # constraint name as a variable (legacy vs ``…_path``), so the + # literal-grep test above cannot see them. Pin both the legacy and + # the ``_path`` shapes behaviorally (design §7.3/§14). + "maxInvest_entity_total", "maxInvest_entity_total_n", + "maxDivest_entity_total", "maxDivest_entity_total_n", + "maxInvest_entity_total_path", "maxInvest_entity_total_path_n", + "maxDivest_entity_total_path", "maxDivest_entity_total_path_n", + "maxDivestGroup_entity_total_path_p", + "maxDivestGroup_entity_total_path_n", ] diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py index d7aa88b5d..2442b3132 100644 --- a/tests/engine_polars/test_recourse_invest_gate.py +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -150,6 +150,34 @@ def test_recourse_committed_output_realized_only(recourse_wf): assert got == {"p2035": 60.0, "p2040": 30.0} +@pytest.mark.solver +def test_recourse_per_path_cap_two_leaf_rows(_resolved): + """§15.1 / §7.2 (D6): under recourse the entity total cap fans into + ONE row per scenario leaf (peaker × {__realized, low}) under the + ``…_path`` name — the legacy single-row ``maxInvest_entity_total`` + (which summed all four periods, 210 > 150, and would spuriously + bind) is gone. Each leaf row sums only its two periods; the D2 + per-leaf-invest test pins those sums to 90 (60+30) and 120 (80+40), + both ≤ 150 → non-binding, hence objective stays 196 875. The naive + all-``d`` sum 210 asymmetry is the proof the caps are per-path.""" + data, _sol = _resolved + from flextool.engine_polars import build_flextool + from polar_high import Problem + + pb = Problem() + build_flextool(pb, data) + names = pb.cstr_names() + assert "maxInvest_entity_total_path" in names + assert "maxInvest_entity_total" not in names # legacy replaced + recs = pb.cstrs_named("maxInvest_entity_total_path") + over = recs[0].over + assert over.height == 2 # (peaker, __realized), (peaker, low) + leaves = sorted(str(v) for v in over["leaf"].to_list()) + assert leaves == ["__realized", "low"] + ents = {str(v) for v in over["p"].to_list()} + assert ents == {"peaker"} + + @pytest.mark.solver def test_recourse_no_invest_na_constraints(_resolved): """§13 (K): the fan starts at the solve's first step, so diff --git a/tests/fixtures/build_stoch_two_period_invest.py b/tests/fixtures/build_stoch_two_period_invest.py index d6024a0bd..09a1ec136 100644 --- a/tests/fixtures/build_stoch_two_period_invest.py +++ b/tests/fixtures/build_stoch_two_period_invest.py @@ -23,9 +23,12 @@ * unit ``peaker`` → ``elec`` — variable cost 0, virtual_unitsize 1 MW, invest_method=invest_total, invest_cost 1 CUR/kW, discount_rate 0.05, lifetime 1 (→ annuity 1000·CRF(0.05,1) = 1050 exactly), - invest_max_total 250 (non-binding at D5; the design's per-path - demonstration value 150 belongs to D6 per-path caps — the legacy - all-``d`` sum 210 would spuriously bind it before per-path caps exist). + invest_max_total 150 (the design §15.1 per-path demonstration value: + each leaf's own total — realized 60+30 = 90, low 80+40 = 120 — is + ≤ 150 so the cap is non-binding PER LEAF, while the legacy all-``d`` + sum 210 would spuriously bind. With D6 per-path caps + ``maxInvest_entity_total_path`` splits the cap by scenario leaf so + the objective stays 196 875). Hand calculation (design §15.1): with the invest axis fanned, per-scenario optimal invest {p2035:60, p2040:30, p2035_low:80, p2040_low:40}; annuity @@ -211,7 +214,7 @@ def build() -> dict: ["unit", "peaker", "invest_cost", _pack(1.0, "float"), "base"], ["unit", "peaker", "discount_rate", _pack(0.05, "float"), "base"], ["unit", "peaker", "lifetime", _pack(1.0, "float"), "base"], - ["unit", "peaker", "invest_max_total", _pack(250.0, "float"), + ["unit", "peaker", "invest_max_total", _pack(150.0, "float"), "base"], ["unit__outputNode", ["peaker", "elec"], "other_operational_cost", _pack(0.0, "float"), "base"], diff --git a/tests/fixtures/stoch_two_period_invest.json b/tests/fixtures/stoch_two_period_invest.json index 5886bbcfe..8d7f5edd0 100644 --- a/tests/fixtures/stoch_two_period_invest.json +++ b/tests/fixtures/stoch_two_period_invest.json @@ -6029,7 +6029,7 @@ "peaker", "invest_max_total", [ - "MjUwLjA=", + "MTUwLjA=", "float" ], "base" From 03f19c0ee2b04d2cf5e2181e9b6f7b6d462aecf6 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 09:24:47 +0300 Subject: [PATCH 15/87] feat(stochastic): D7 handoff explicit non-realized drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the recourse handoff's drop of branch-named (non-realized-scenario) v_invest rows EXPLICIT (design sliceD §10.1 (H)). - build_handoff_from_solution: partition invest_by_ed on synthetic branch-token membership BEFORE the commit loop and debug-log the dropped mass; the commit gate previously dropped these by accident. The Slice C Guard 3 assertion stays as a belt-and-braces invariant check (no synthetic token may survive into committed rows), not the mechanism. Flag-off / deterministic: synthetic set empty → no-op (byte-parity). - Gate test: the pre-filter drops the low leaf's 80+40 = 120 MW, debug- logs it (periods p2035_low/p2040_low), and commits only the realized 60/30; Guard 3 stays inert. Guard 1 raw-value lift was already landed at D5. Flag-off parity (handoff cumulative carriers, continuation fanout, recourse guards) green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/input.py | 25 ++++++++++++++++ .../test_recourse_invest_gate.py | 29 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index d38819d17..899e96722 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5719,6 +5719,31 @@ def build_handoff_from_solution( e = str(r[entity_col]) divest_by_e[e] = divest_by_e.get(e, 0.0) + v + # ---- Slice D (H, §10.1): EXPLICIT non-realized invest drop ---- + # ``invest_by_ed`` collects every solved v_invest row, including + # branch-named (non-realized-scenario) ones fanned in under recourse. + # Historically the ``d in realize_invest`` commit gate below dropped + # those by accident; make it explicit here — partition on synthetic + # branch membership and debug-log the dropped mass BEFORE the commit + # loop, so the belt-and-braces Slice C Guard 3 (which asserts no + # synthetic token survived into the committed rows) is a true + # invariant check, not the mechanism. Flag-off / deterministic: + # ``_synth`` is empty and this block is a no-op (byte-parity). + _synth_handoff = synthetic_branch_tokens( + getattr(flex_data, "period_branch_full", None)) + if _synth_handoff: + _dropped = {k: v for k, v in invest_by_ed.items() + if k[1] in _synth_handoff} + if _dropped: + logging.getLogger("flextool.engine_polars.input").debug( + "recourse handoff: dropping %d non-realized invest rows, " + "total capacity %.6g (periods %s)", + len(_dropped), sum(_dropped.values()), + sorted({k[1] for k in _dropped}), + ) + invest_by_ed = {k: v for k, v in invest_by_ed.items() + if k[1] not in _synth_handoff} + # ---- iteration set: prior keys ∪ entity × iteration_periods ---- realize_invest = _read_realize_invest_periods( sd / "realized_invest_periods_of_current_solve.csv", diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py index 2442b3132..15f8db02e 100644 --- a/tests/engine_polars/test_recourse_invest_gate.py +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -178,6 +178,35 @@ def test_recourse_per_path_cap_two_leaf_rows(_resolved): assert ents == {"peaker"} +@pytest.mark.solver +def test_recourse_handoff_drops_non_realized(_resolved, recourse_wf, caplog): + """§10.1 (D7): the handoff pre-filter drops branch-named v_invest rows + (non-realized scenario) BEFORE the commit loop, debug-logging the + dropped mass (low leaf 80 + 40 = 120), and commits only real periods + (60 / 30). Guard 3 stays inert (no raise).""" + import logging + + data, sol = _resolved + from flextool.engine_polars.input import build_handoff_from_solution + + with caplog.at_level(logging.DEBUG, + logger="flextool.engine_polars.input"): + handoff = build_handoff_from_solution( + sol, recourse_wf, SOLVE, flex_data=data) + assert handoff is not None + ri = handoff.realized_invest + periods = {str(r["period"]) for r in ri.iter_rows(named=True) + if float(r["value"]) > 0} + assert periods == {"p2035", "p2040"} + assert not (periods & {"p2035_low", "p2040_low"}) + # The pre-filter debug-logged the dropped low-branch mass = 120. + msgs = [r.getMessage() for r in caplog.records + if "non-realized invest" in r.getMessage()] + assert msgs, "pre-filter debug log not emitted" + assert "120" in msgs[0] + assert "p2035_low" in msgs[0] and "p2040_low" in msgs[0] + + @pytest.mark.solver def test_recourse_no_invest_na_constraints(_resolved): """§13 (K): the fan starts at the solve's first step, so From f7b724d9530b966ec350522f41c9304498c5505c Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 09:52:49 +0300 Subject: [PATCH 16/87] feat(stochastic): D8 outputs/costs + fixture variant B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outputs, cost reconciliation, and the cross-scenario-coupling fixture (design sliceD §11 (I) / §15.5 / §15.6). Outputs (§11.2): - read_highs_solution: under recourse AND output_horizon, union the horizon-expanded d_realize_dispatch_or_invest set (branch periods included) into the invest-axis canonical order, so non-realized branch v_invest rows surface. Gated on both flags → byte-parity for deterministic models and for recourse without horizon output. Committed path stays realized-only (§11.1). Costs (§11.3, choice I-a): - calc_costs probability-weights the invest/divest + lifetime-fixed cost rows by pd_branch_weight under recourse, so the cost breakdown reconciles to the LP objective's (expected) invest contribution rather than over-reporting the realized spend by 1/w_realized. read_parameters threads par.pd_branch_weight + par.recourse_invest. Gated → factor 1.0 off-flag (byte-parity; deterministic cost-aggregation goldens green). - test_npv_cluster skips the eager-vs-lazy NPV byte-equality under recourse (the eager reference is not lineage-aware, §11.4). Fixtures/tests: - variant A gains a recourse_horizon scenario (model.output_horizon=yes); gate test asserts branch rows 80@p2035_low / 40@p2040_low surface. - variant B (stoch_two_period_invest_na): extends the proven stochastics.json storage shape with a recourse_na scenario making the stochastic-group storage node investable. Witnesses: non_anticipativity_storage_use fires (>0 rows), branch v_state == the NA-tied realized value, and the node-side annu_n objective coefficient carries pd_branch_weight (the F3 node-side pin). - cost reconciliation e2e: reported realized invest cost 0.039375 M = 0.25·(60·2100 + 30·1050); solver objective 0.196875 M. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/process_outputs/calc_costs.py | 31 + .../process_outputs/read_highs_solution.py | 82 + flextool/process_outputs/read_parameters.py | 10 + tests/conftest.py | 27 + tests/engine_polars/test_npv_cluster.py | 11 + .../test_recourse_invest_gate.py | 23 + .../engine_polars/test_recourse_invest_na.py | 168 + .../fixtures/build_stoch_two_period_invest.py | 11 + .../build_stoch_two_period_invest_na.py | 137 + tests/fixtures/stoch_two_period_invest.json | 44 + .../fixtures/stoch_two_period_invest_na.json | 6715 +++++++++++++++++ tests/test_recourse_cost_reconciliation.py | 107 + 12 files changed, 7366 insertions(+) create mode 100644 tests/engine_polars/test_recourse_invest_na.py create mode 100644 tests/fixtures/build_stoch_two_period_invest_na.py create mode 100644 tests/fixtures/stoch_two_period_invest_na.json create mode 100644 tests/test_recourse_cost_reconciliation.py diff --git a/flextool/process_outputs/calc_costs.py b/flextool/process_outputs/calc_costs.py index 445dbeff1..f330c4147 100644 --- a/flextool/process_outputs/calc_costs.py +++ b/flextool/process_outputs/calc_costs.py @@ -1,3 +1,4 @@ +import numpy as np import pandas as pd from flextool.process_outputs._annualize import annualize_dt_to_d @@ -217,6 +218,36 @@ def _per_process_commodity_cost(flow_frame): r.cost_entity_fixed_invested = (v.invest.mul(par.entity_unitsize[v.invest.columns] * par.entity_lifetime_fixed_cost[v.invest.columns])) r.cost_entity_fixed_divested = -(v.divest.mul(par.entity_unitsize[v.divest.columns] * par.entity_lifetime_fixed_cost_divest[v.divest.columns])) + # --- Slice D (I-a, §11.3): probability-weight the invest/divest cost --- + # Under ``recourse`` the LP objective weights every invest/divest + # annuity + lifetime-fixed contribution by ``pd_branch_weight[period]`` + # (§9.1). The post-hoc recompute above uses the raw annuity, so the + # reported cost would over-state the realized invest by ``1/w_R``. + # Multiply each invest/divest cost row by its period's branch weight so + # the cost breakdown reconciles to the reported (expected) objective. + # Gated on the recourse flag — absent / off → factor 1.0 (byte-parity). + if getattr(par, "recourse_invest", False): + _wser = getattr(par, "pd_branch_weight", None) + _wmap: dict[str, float] = {} + if _wser is not None: + for _idx, _val in _wser.items(): + _period = _idx[-1] if isinstance(_idx, tuple) else _idx + _wmap[str(_period)] = float(_val) + + def _weight_by_branch(df): + if df is None or df.empty: + return df + _periods = df.index.get_level_values('period') + _factors = np.array([_wmap.get(str(_p), 1.0) for _p in _periods]) + return df.mul(_factors, axis=0) + + r.cost_entity_invest_d = _weight_by_branch(r.cost_entity_invest_d) + r.cost_entity_divest_d = _weight_by_branch(r.cost_entity_divest_d) + r.cost_entity_fixed_invested = _weight_by_branch( + r.cost_entity_fixed_invested) + r.cost_entity_fixed_divested = _weight_by_branch( + r.cost_entity_fixed_divested) + # --- Aggregate operational costs (depends on r.costPenalty_* from compute_slacks) --- r.costOper_dt = (r.cost_commodity_dt.sum(axis=1) - r.sales_commodity_dt.sum(axis=1) + diff --git a/flextool/process_outputs/read_highs_solution.py b/flextool/process_outputs/read_highs_solution.py index 74a9dd5a1..a31efe164 100644 --- a/flextool/process_outputs/read_highs_solution.py +++ b/flextool/process_outputs/read_highs_solution.py @@ -638,6 +638,71 @@ def _load_realized_invest_periods_list( return list(realized["period"].astype(str).to_list()) +def _output_horizon_enabled( + work_folder: "Path | str | None", + *, + provider: "object | None" = None, +) -> bool: + """True when ``model.output_horizon`` is enabled for this solve. + + Reads ``solve_data/enable_optional_outputs.csv`` (the same flag set + ``_emit_per_solve`` consults to source horizon vs realized rows). + Provider-first, disk fallback; missing → False. + """ + if work_folder is None: + return False + path = Path(work_folder) / "solve_data" / "enable_optional_outputs.csv" + frame = _provider_lookup(provider, path) + if frame is not None: + try: + return "output_horizon" in { + str(v) for v in frame["flag"].to_list() if v is not None} + except Exception: # noqa: BLE001 + return False + if not path.exists(): + return False + try: + df = pd.read_csv(path) + return "output_horizon" in { + str(v) for v in df["flag"].astype(str).to_list()} + except Exception: # noqa: BLE001 + return False + + +def _load_dispatch_or_invest_periods( + work_folder: "Path | str | None", + *, + provider: "object | None" = None, +) -> list[str]: + """Return the horizon-expanded ``d_realize_dispatch_or_invest`` period + list (Slice D §11.2). + + Under ``output_horizon`` this set (emitted by ``_emit_per_solve``) + unions the branch (non-realized) dispatch periods with the realized + invest periods, so it carries the branch-suffixed period tokens the + in-memory ``realized_dispatch`` shortcut omits. Provider-first, disk + fallback; empty list when unavailable. + """ + if work_folder is None: + return [] + path = (Path(work_folder) / "solve_data" + / "d_realize_dispatch_or_invest_set.csv") + frame = _provider_lookup(provider, path) + if frame is not None: + try: + return [str(v) for v in frame["period"].to_list() + if v is not None] + except Exception: # noqa: BLE001 + return [] + if not path.exists(): + return [] + try: + df = pd.read_csv(path, dtype=str) + return [str(v) for v in df["period"].to_list() if v is not None] + except Exception: # noqa: BLE001 + return [] + + def _name_regex(var_name: str) -> re.Pattern[str]: """Return a compiled regex matching ``[...]``.""" return re.compile(rf"^{re.escape(var_name)}\[(.+)\]$") @@ -938,6 +1003,23 @@ def extract_variable( ) if realized_invest is not None and not realized_invest: canonical_d = [] + elif (getattr(flex_data, "recourse_invest", False) + and _output_horizon_enabled(work_folder, provider=provider)): + # Slice D §11.2: surface the non-realized branch invest + # rows under ``output_horizon``. The in-memory + # ``realized_dispatch`` shortcut in + # ``_load_canonical_d_order`` is anchor-only (real names), + # so union the horizon-expanded invest set (branch periods + # included) into the canonical order. Gated on recourse + # AND output_horizon — byte-parity for every other model + # (deterministic, or recourse without horizon output). + horizon_d = _load_dispatch_or_invest_periods( + work_folder, provider=provider) + if horizon_d: + base = list(canonical_d or []) + seen = set(base) + canonical_d = base + [ + p for p in horizon_d if p not in seen] canonical_rows = [(d,) for d in canonical_d] if canonical_d is not None else None else: canonical_rows = [()] # one row: just (solve,) diff --git a/flextool/process_outputs/read_parameters.py b/flextool/process_outputs/read_parameters.py index 8410704fa..e820bbc72 100644 --- a/flextool/process_outputs/read_parameters.py +++ b/flextool/process_outputs/read_parameters.py @@ -1430,6 +1430,16 @@ def read_parameters( densify_entities=_entity_universe, ) + # Slice D (I-a, §11.3): per-period probability weight + the recourse + # flag, so ``calc_costs`` can weight the reported invest/divest cost by + # ``pd_branch_weight`` under recourse (reconciling the cost breakdown to + # the probability-weighted LP objective). Flag absent / off → the + # weighting is skipped (byte-parity). + p.recourse_invest = bool(getattr(flex_data, "recourse_invest", False)) + p.pd_branch_weight = _pd_series_solve_period( + getattr(flex_data, "pd_branch_weight", None), solve_name=solve_name, + ) + # inflation factors — Series((solve, period)). p.inflation_factor_operations_yearly = _pd_series_solve_period( flex_data.p_inflation_op, solve_name=solve_name, diff --git a/tests/conftest.py b/tests/conftest.py index 2934b9dc1..a306c8ac8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -511,6 +511,30 @@ def stoch_two_period_invest_db_url( return url +@pytest.fixture(scope="session") +def stoch_two_period_invest_na_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_invest_na`` fixture DB — Slice D variant B + (cross-scenario coupling; design §15.6). + + Built from ``tests/fixtures/stoch_two_period_invest_na.json`` + (generated by ``tests/fixtures/build_stoch_two_period_invest_na.py``): + the proven ``stochastics.json`` storage shape extended with a + ``recourse_na`` scenario that makes the stochastic-group storage node + investable under ``recourse``. Defensive migration as in + :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stpina") + / "stoch_two_period_invest_na.sqlite") + url = json_to_db( + FIXTURES_DIR / "stoch_two_period_invest_na.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -562,6 +586,7 @@ def case14_db_url(tmp_path_factory: pytest.TempPathFactory) -> str: "stochastics_pbt_inflow": "stochastics_pbt_inflow_db_url", "stoch_two_period": "stoch_two_period_db_url", "stoch_two_period_invest": "stoch_two_period_invest_db_url", + "stoch_two_period_invest_na": "stoch_two_period_invest_na_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -605,6 +630,7 @@ def scenario_workdir( stochastics_pbt_inflow_db_url, stoch_two_period_db_url, stoch_two_period_invest_db_url, + stoch_two_period_invest_na_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -661,6 +687,7 @@ def test_foo(scenario_workdir): "stochastics_pbt_inflow": stochastics_pbt_inflow_db_url, "stoch_two_period": stoch_two_period_db_url, "stoch_two_period_invest": stoch_two_period_invest_db_url, + "stoch_two_period_invest_na": stoch_two_period_invest_na_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/engine_polars/test_npv_cluster.py b/tests/engine_polars/test_npv_cluster.py index 49e9438f0..2870078bc 100644 --- a/tests/engine_polars/test_npv_cluster.py +++ b/tests/engine_polars/test_npv_cluster.py @@ -154,6 +154,17 @@ def test_npv_lazy_vs_eager_parity( # eager NPV fields via the existing apply_derived_f path. data_eager = load_flextool(work, db_reader=reader) + # Slice D (§11.4): the eager reference + # ``ed_entity_annual_family_from_source`` embeds its own period walk + # and is NOT lineage-aware, so under ``recourse`` its per-leaf annuity + # windows diverge from the lazy lineage-filtered path by design. The + # byte-equality assumption is valid only while lineage is None — skip + # the sweep on any flag-on fixture (guards against a recourse fixture + # ever entering PARITY_CASES). + if getattr(data_eager, "recourse_invest", False): + pytest.skip("eager-vs-lazy NPV parity does not hold under " + "recourse (lineage-filtered windows; design §11.4)") + # Re-read via the lazy entry points and compare per-field. provider = _seed_workdir_provider(work) active_solve = _read_active_solve(work, provider=provider) diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py index 15f8db02e..b2ea29576 100644 --- a/tests/engine_polars/test_recourse_invest_gate.py +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -150,6 +150,29 @@ def test_recourse_committed_output_realized_only(recourse_wf): assert got == {"p2035": 60.0, "p2040": 30.0} +@pytest.fixture(scope="module") +def horizon_wf(scenario_workdir): + """Cascade run of the recourse fixture with ``model.output_horizon`` + enabled — the non-realized branch invest rows must surface (§11.2).""" + return scenario_workdir("recourse_horizon", + db_fixture="stoch_two_period_invest") + + +@pytest.mark.solver +def test_recourse_output_horizon_branch_rows(horizon_wf): + """§11.2 / §15.5 (D8): under ``output_horizon`` the invest-axis output + surfaces the non-realized branch rows (80 at p2035_low, 40 at + p2040_low) alongside the committed realized rows (60 / 30).""" + inv = pl.read_parquet( + horizon_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") + got = {r["period"]: round(float(r["peaker"]), 6) + for r in inv.iter_rows(named=True)} + assert got.get("p2035") == 60.0 + assert got.get("p2040") == 30.0 + assert got.get("p2035_low") == 80.0 + assert got.get("p2040_low") == 40.0 + + @pytest.mark.solver def test_recourse_per_path_cap_two_leaf_rows(_resolved): """§15.1 / §7.2 (D6): under recourse the entity total cap fans into diff --git a/tests/engine_polars/test_recourse_invest_na.py b/tests/engine_polars/test_recourse_invest_na.py new file mode 100644 index 000000000..41ac55307 --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_na.py @@ -0,0 +1,168 @@ +"""Slice D — variant B (cross-scenario coupling) structural tests. + +Design: ``specs/sliceD_recourse_invest_design.md`` §15.6. + +Fixture ``stoch_two_period_invest_na.json`` (built via +``build_stoch_two_period_invest_na.py``): the proven ``stochastics.json`` +storage shape (``2_day_stochastic_dispatch``) extended with a +``recourse_na`` scenario that makes the stochastic-group storage node +``hydro_reservoir`` investable under ``recourse``. Unlike variant A the +LP is NOT separable: a stochastic-group NODE fires the dispatch +non-anticipativity net-charge pinning (``non_anticipativity_storage_use``). +""" +from __future__ import annotations + +import os + +import pytest + +SOLVE = "2day_dispatch" + + +@pytest.fixture(scope="module") +def na_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("recourse_na", + db_fixture="stoch_two_period_invest_na") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def _built(na_wf): + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(na_wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, pb, sol + + +@pytest.mark.solver +def test_na_coupling_active(_built): + """§15.6 witness 1: the storage non-anticipativity family fires (the + flag-on LP is NOT separable per leaf).""" + data, pb, _sol = _built + assert data.dt_non_anticipativity is not None + assert data.dt_non_anticipativity.height > 0 + recs = pb.cstrs_named("non_anticipativity_storage_use") + n_rows = sum(r.over.height for r in recs) + assert n_rows > 0 + + +@pytest.mark.solver +def test_na_recourse_active(_built): + data, _pb, _sol = _built + assert data.recourse_invest is True + + +@pytest.mark.solver +def test_na_node_invest_coefficient_branch_weighted(_built): + """§15.6 witness 3 (the F3 node-side regression hook): the objective + coefficient of ``v_invest_n[hydro_reservoir, d]`` equals + ``unitsize × annu_n[d] × pd_branch_weight[d]`` — i.e. the node-side + annuity carries the per-period branch weight.""" + data, pb, _sol = _built + m = pb.canonicalise() + + # annu_n[d] and pd_branch_weight[d] for hydro_reservoir. + annu = {str(r["d"]): float(r["value"]) + for r in data.ed_entity_annual_discounted.frame.iter_rows(named=True) + if str(r["e"]) == "hydro_reservoir"} + wts = {str(r["d"]): float(r["value"]) + for r in data.pd_branch_weight.frame.iter_rows(named=True)} + us_frame = data.p_state_unitsize.frame + us = {str(r[us_frame.columns[0]]): float(r["value"]) + for r in us_frame.iter_rows(named=True)} + unit = us.get("hydro_reservoir", 1.0) + + # Objective coefficients keyed by v_invest_n column name. + obj = {name: float(c) + for name, c in zip(m.col_names, m.col_obj) + if name.startswith("v_invest_n[")} + assert obj, "no v_invest_n columns in the LP" + + # Parse the period token out of each column name and check the + # coefficient against unitsize × annu_n × weight. + checked = 0 + for name, coeff in obj.items(): + inner = name[len("v_invest_n["):-1] + parts = [p.strip() for p in inner.split(",")] + if parts[0] != "hydro_reservoir": + continue + period = parts[-1] + if period not in annu or period not in wts: + continue + expected = unit * annu[period] * wts[period] + assert coeff == pytest.approx(expected, rel=1e-6), ( + f"{name}: coeff {coeff} != unitsize({unit}) × " + f"annu_n({annu[period]}) × weight({wts[period]}) = {expected}" + ) + checked += 1 + assert checked >= 2, ( + f"expected ≥2 weighted v_invest_n[hydro_reservoir, ·] coefficients, " + f"checked {checked}") + + # At least one weighted period carries a genuine (non-unit) factor — + # guards against an unweighted regression that would leave every + # coefficient == unitsize × annu_n (pd_branch_weight normalises the + # equal-weight branch cohort to < 1 per leaf). + weight_vals = {wts[p] for p in annu if p in wts} + assert any(abs(w - 1.0) > 1e-9 for w in weight_vals), ( + f"branch weights all 1.0 ({weight_vals}) — the coefficient check " + "would not discriminate an unweighted regression") + + +@pytest.mark.solver +def test_na_branch_state_equals_realized(_built): + """§15.6 witness 2: at the NA-tied timesteps the branch-leaf + ``v_state`` equals the realized value (coupling active in the + solution).""" + data, _pb, sol = _built + if "v_state" not in sol._vars: + pytest.skip("fixture has no v_state variable") + vs = sol.value("v_state") + # Group v_state by (node, time) across branch periods; every period + # sharing a (node, time) NA tie must carry the same value. + from collections import defaultdict + + tol = 1e-6 + # dt_non_anticipativity carries the (d, t) tie set; the storage NA + # equates the net-charge, so branch v_state at those (n, t) equals the + # realized value. We assert the weaker, robust invariant: for each + # (node, time), the spread across the periods present is within tol. + by_nt: dict[tuple[str, str], list[float]] = defaultdict(list) + cols = vs.columns + ncol = "n" if "n" in cols else ("node" if "node" in cols else cols[0]) + tcol = "t" if "t" in cols else ("time" if "time" in cols else None) + if tcol is None: + pytest.skip("v_state frame has no time column") + for r in vs.iter_rows(named=True): + by_nt[(str(r[ncol]), str(r[tcol]))].append(float(r["value"])) + coupled = 0 + for (_n, _t), vals in by_nt.items(): + if len(vals) > 1: + assert max(vals) - min(vals) <= tol + 1e-6 * max(1.0, max(abs(v) for v in vals)) + coupled += 1 + assert coupled > 0, "no shared (node, time) v_state rows to witness coupling" + + +@pytest.mark.solver +def test_na_preconditions_clean(_built): + """§15.6 witness 4: recourse NPV preconditions hold on this fixture. + + ``load_flextool`` invokes ``assert_recourse_npv_preconditions`` at the + lineage-activation boundaries (D4); a violation would have raised + during the cascade. The ``_built`` fixture reaching an optimal solve + is therefore the end-to-end precondition witness.""" + data, _pb, sol = _built + assert sol.optimal + assert data.recourse_invest is True diff --git a/tests/fixtures/build_stoch_two_period_invest.py b/tests/fixtures/build_stoch_two_period_invest.py index 09a1ec136..46eb7da93 100644 --- a/tests/fixtures/build_stoch_two_period_invest.py +++ b/tests/fixtures/build_stoch_two_period_invest.py @@ -108,15 +108,23 @@ def build() -> dict: ["base", "System data (peaker invest + wind + elec demand)"], ["stoch", "Stochastic branches + branch-varying wind profile"], ["recourse", "stochastic_invest_method = recourse"], + ["horizon", "model.output_horizon = yes (branch invest rows)"], ] spec["scenarios"] = [ ["recourse", False, "Per-scenario stochastic investment (flag on)"], + ["recourse_horizon", False, + "Recourse + output_horizon (non-realized branch invest visible)"], ] spec["scenario_alternatives"] = [ ["recourse", "init", "base"], ["recourse", "base", "stoch"], ["recourse", "stoch", "recourse"], ["recourse", "recourse", None], + ["recourse_horizon", "init", "base"], + ["recourse_horizon", "base", "stoch"], + ["recourse_horizon", "stoch", "recourse"], + ["recourse_horizon", "recourse", "horizon"], + ["recourse_horizon", "horizon", None], ] spec["entities"] = [ @@ -228,6 +236,9 @@ def build() -> dict: # ---- recourse: opt-in flag ------------------------------------- ["solve", "stoch_2p_inv", "stochastic_invest_method", _pack("recourse", "str"), "recourse"], + # ---- horizon: surface non-realized branch invest (§11.2) ------- + ["model", "flextool", "output_horizon", _pack("yes", "str"), + "horizon"], ] return spec diff --git a/tests/fixtures/build_stoch_two_period_invest_na.py b/tests/fixtures/build_stoch_two_period_invest_na.py new file mode 100644 index 000000000..5c2ab7105 --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_invest_na.py @@ -0,0 +1,137 @@ +"""Build ``tests/fixtures/stoch_two_period_invest_na.json`` — the Slice D +variant-B (cross-scenario coupling) fixture (design +``specs/sliceD_recourse_invest_design.md`` §15.6). + +Variant A (``stoch_two_period_invest``) authors NO stochastic *node*, so +its LP is separable per leaf (which makes the hand-calc tractable). +Variant B exercises the coupling path the feature must NOT be gated away +from: a stochastic-group NODE fires the dispatch non-anticipativity +net-charge pinning (``non_anticipativity_storage_use``, gated on +``group_node`` × ``groupStochastic``), so the flag-on LP is genuinely +NOT separable. + +Rather than author a storage node + branch machinery from scratch, this +builder EXTENDS the proven ``stochastics.json`` shape (the +``2_day_stochastic_dispatch`` scenario, which already fires +``non_anticipativity_storage_use`` with a populated +``dt_non_anticipativity`` — see +``tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py``) +with a new ``recourse_na`` scenario that: + + * turns on ``solve.stochastic_invest_method = recourse`` for + ``2day_dispatch``; + * gives the solve ``invest_periods`` / ``realized_invest_periods`` = + ``[period1]`` so the invest axis fans across the period-1 branches; + * makes the stochastic-group storage node ``hydro_reservoir`` + INVESTABLE (``invest_method = invest_total``), so its node-side + objective annuity ``annu_n`` (the §9.1 #5 regression hook) is + exercised and — under recourse — probability-weighted by + ``pd_branch_weight[d]``. + +Assertions (structural + directional, no second hand-calc) live in +``tests/engine_polars/test_recourse_invest_na.py``: + * ``dt_non_anticipativity`` non-empty; ``non_anticipativity_storage_use`` + row count > 0 — the coupling is live. + * branch-leaf ``v_state`` at the NA-tied timesteps equals the realized + value (the flag-on LP is not separable on this fixture). + * the objective coefficient of ``v_invest_n[hydro_reservoir, d]`` equals + ``unitsize × annu_n[d] × pd_branch_weight[d]`` (the D4 analog for the + node-side Param). + * feasible / optimal; ``check_recourse_npv_preconditions`` → ``[]``. + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_two_period_invest_na.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + + +def _pack(value: object, value_type: str) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def build() -> dict: + spec = json.loads((FIXTURES / "stochastics.json").read_text()) + + # ``stochastics.json`` predates v70, so inject the + # ``solve.stochastic_invest_method`` opt-in (value list + definition) + # before import — ``json_to_db`` validates authored values against the + # embedded schema BEFORE the idempotent v70 migration reconciles it. + if not any(vl[0] == "stochastic_invest_methods" + for vl in spec["parameter_value_lists"]): + spec["parameter_value_lists"] += [ + ["stochastic_invest_methods", _pack("none", "str")], + ["stochastic_invest_methods", _pack("recourse", "str")], + ] + if not any(pd[0] == "solve" and pd[1] == "stochastic_invest_method" + for pd in spec["parameter_definitions"]): + spec["parameter_definitions"].append([ + "solve", "stochastic_invest_method", + _pack("none", "str"), "stochastic_invest_methods", + "How stochastic branches participate in investment " + "(none/recourse).", "solve_advanced", + ]) + + # New alternative + scenario layered on the 2_day_stochastic_dispatch + # chain (base → init → system → 2day → recourse_na). + spec["alternatives"].append( + ["recourse_na", "Recourse invest on the stochastic storage node"]) + spec["scenarios"].append( + ["recourse_na", False, + "Variant B — cross-scenario coupling (stochastic storage node)"]) + spec["scenario_alternatives"] += [ + ["recourse_na", "base", "init"], + ["recourse_na", "init", "system"], + ["recourse_na", "system", "2day"], + ["recourse_na", "2day", "recourse_na"], + ["recourse_na", "recourse_na", None], + ] + + # Recourse opt-in + invest axis + an investable stochastic storage + # node. ``hydro_reservoir`` is already a ``storage`` node in the + # ``add_stochastics`` group (system alternative), so making it + # investable exercises the node-side ``annu_n`` under the fanned + # invest axis while the storage NA keeps the leaves coupled. + spec["parameter_values"] += [ + ["solve", "2day_dispatch", "stochastic_invest_method", + _pack("recourse", "str"), "recourse_na"], + ["solve", "2day_dispatch", "invest_periods", + _pack({"value_type": "str", "data": ["period1"]}, "array"), + "recourse_na"], + ["solve", "2day_dispatch", "realized_invest_periods", + _pack({"value_type": "str", "data": ["period1"]}, "array"), + "recourse_na"], + ["solve", "2day_dispatch", "years_represented", + _pack({"index_type": "str", "data": [["period1", 1.0]]}, "map"), + "recourse_na"], + ["node", "hydro_reservoir", "invest_method", + _pack("invest_total", "str"), "recourse_na"], + ["node", "hydro_reservoir", "invest_cost", + _pack(1.0, "float"), "recourse_na"], + ["node", "hydro_reservoir", "discount_rate", + _pack(0.05, "float"), "recourse_na"], + ["node", "hydro_reservoir", "lifetime", + _pack(1.0, "float"), "recourse_na"], + ["node", "hydro_reservoir", "invest_max_total", + _pack(100000.0, "float"), "recourse_na"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_invest_na.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_invest.json b/tests/fixtures/stoch_two_period_invest.json index 8d7f5edd0..7d071ea82 100644 --- a/tests/fixtures/stoch_two_period_invest.json +++ b/tests/fixtures/stoch_two_period_invest.json @@ -5607,6 +5607,10 @@ [ "recourse", "stochastic_invest_method = recourse" + ], + [ + "horizon", + "model.output_horizon = yes (branch invest rows)" ] ], "scenarios": [ @@ -5614,6 +5618,11 @@ "recourse", false, "Per-scenario stochastic investment (flag on)" + ], + [ + "recourse_horizon", + false, + "Recourse + output_horizon (non-realized branch invest visible)" ] ], "scenario_alternatives": [ @@ -5636,6 +5645,31 @@ "recourse", "recourse", null + ], + [ + "recourse_horizon", + "init", + "base" + ], + [ + "recourse_horizon", + "base", + "stoch" + ], + [ + "recourse_horizon", + "stoch", + "recourse" + ], + [ + "recourse_horizon", + "recourse", + "horizon" + ], + [ + "recourse_horizon", + "horizon", + null ] ], "entities": [ @@ -6086,6 +6120,16 @@ "str" ], "recourse" + ], + [ + "model", + "flextool", + "output_horizon", + [ + "InllcyI=", + "str" + ], + "horizon" ] ] } diff --git a/tests/fixtures/stoch_two_period_invest_na.json b/tests/fixtures/stoch_two_period_invest_na.json new file mode 100644 index 000000000..7e34e20fa --- /dev/null +++ b/tests/fixtures/stoch_two_period_invest_na.json @@ -0,0 +1,6715 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "entities": [ + [ + "commodity", + "coal", + null + ], + [ + "commodity", + "gas", + null + ], + [ + "constraint", + "hydro_divide", + null + ], + [ + "group", + "add_stochastics", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "coal_market", + null + ], + [ + "node", + "demand_node", + null + ], + [ + "node", + "downriver", + null + ], + [ + "node", + "gas_market", + null + ], + [ + "node", + "hydro_reservoir", + null + ], + [ + "profile", + "wind", + null + ], + [ + "solve", + "1week_rolling", + null + ], + [ + "solve", + "2day_dispatch", + null + ], + [ + "timeline", + "y2020", + null + ], + [ + "timeset", + "1week", + null + ], + [ + "timeset", + "2day", + null + ], + [ + "unit", + "coal_plant", + null + ], + [ + "unit", + "gas_plant", + null + ], + [ + "unit", + "hydro_plant", + null + ], + [ + "unit", + "wind_plant", + null + ], + [ + "commodity__node", + [ + "coal", + "coal_market" + ], + null + ], + [ + "commodity__node", + [ + "gas", + "gas_market" + ], + null + ], + [ + "group__node", + [ + "add_stochastics", + "hydro_reservoir" + ], + null + ], + [ + "group__unit", + [ + "add_stochastics", + "wind_plant" + ], + null + ], + [ + "unit__inputNode", + [ + "coal_plant", + "coal_market" + ], + null + ], + [ + "unit__inputNode", + [ + "gas_plant", + "gas_market" + ], + null + ], + [ + "unit__inputNode", + [ + "hydro_plant", + "hydro_reservoir" + ], + null + ], + [ + "unit__outputNode", + [ + "coal_plant", + "demand_node" + ], + null + ], + [ + "unit__outputNode", + [ + "gas_plant", + "demand_node" + ], + null + ], + [ + "unit__outputNode", + [ + "hydro_plant", + "demand_node" + ], + null + ], + [ + "unit__outputNode", + [ + "hydro_plant", + "downriver" + ], + null + ], + [ + "unit__outputNode", + [ + "wind_plant", + "demand_node" + ], + null + ], + [ + "unit__node__profile", + [ + "wind_plant", + "demand_node", + "wind" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "coal_market" + ], + "system", + true + ], + [ + "node", + [ + "demand_node" + ], + "system", + true + ], + [ + "node", + [ + "downriver" + ], + "system", + true + ], + [ + "node", + [ + "gas_market" + ], + "system", + true + ], + [ + "node", + [ + "hydro_reservoir" + ], + "system", + true + ], + [ + "unit", + [ + "coal_plant" + ], + "system", + true + ], + [ + "unit", + [ + "gas_plant" + ], + "system", + true + ], + [ + "unit", + [ + "hydro_plant" + ], + "system", + true + ], + [ + "unit", + [ + "wind_plant" + ], + "system", + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "Njk=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment (none/recourse).", + "solve_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "parameter_values": [ + [ + "commodity", + "coal", + "price", + [ + "MTY=", + "float" + ], + "system" + ], + [ + "commodity", + "gas", + "price", + [ + "Ng==", + "float" + ], + "system" + ], + [ + "constraint", + "hydro_divide", + "constant", + [ + "MA==", + "float" + ], + "system" + ], + [ + "constraint", + "hydro_divide", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "system" + ], + [ + "group", + "add_stochastics", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "init" + ], + [ + "model", + "flextool", + "output_horizon", + [ + "InllcyI=", + "str" + ], + "init" + ], + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyIxd2Vla19yb2xsaW5nIl19", + "array" + ], + "1week_rolling" + ], + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyIyZGF5X2Rpc3BhdGNoIl19", + "array" + ], + "2day" + ], + [ + "node", + "coal_market", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "Base" + ], + [ + "node", + "coal_market", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "system" + ], + [ + "node", + "demand_node", + "inflow", + [ + "LTMwMDA=", + "float" + ], + "system" + ], + [ + "node", + "demand_node", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "Base" + ], + [ + "node", + "demand_node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "system" + ], + [ + "node", + "demand_node", + "penalty_down", + [ + "NTAwMA==", + "float" + ], + "system" + ], + [ + "node", + "demand_node", + "penalty_up", + [ + "NTAwMA==", + "float" + ], + "system" + ], + [ + "node", + "downriver", + "inflow", + [ + "LTEwMA==", + "float" + ], + "system" + ], + [ + "node", + "downriver", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "Base" + ], + [ + "node", + "downriver", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "system" + ], + [ + "node", + "downriver", + "penalty_down", + [ + "MA==", + "float" + ], + "system" + ], + [ + "node", + "gas_market", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "Base" + ], + [ + "node", + "gas_market", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "existing", + [ + "NTAwMA==", + "float" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "inflow", + [ + "NDAw", + "float" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "Base" + ], + [ + "node", + "hydro_reservoir", + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ], + "no_storage_override" + ], + [ + "node", + "hydro_reservoir", + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "storage_solve_horizon_method", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "storage_start_end_method", + [ + "ImZpeF9zdGFydCI=", + "str" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "storage_state_reference_value", + [ + "MC41", + "float" + ], + "system" + ], + [ + "node", + "hydro_reservoir", + "storage_state_start", + [ + "MC41", + "float" + ], + "system" + ], + [ + "profile", + "wind", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmVhbGl6ZWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC41XSwgWyJ0MDAwMiIsIDAuNDU5ODQ4NTA4XSwgWyJ0MDAwMyIsIDAuNDE0NzM1NTIxXSwgWyJ0MDAwNCIsIDAuMzg4MDgyOTIyXSwgWyJ0MDAwNSIsIDAuMzUyNTMyMTU2XSwgWyJ0MDAwNiIsIDAuMzk4MjkxNDA1XSwgWyJ0MDAwNyIsIDAuNDA3NTg4NTAzXSwgWyJ0MDAwOCIsIDAuNDY5NDU5MjY0XSwgWyJ0MDAwOSIsIDAuNDUyMjUwODEzXSwgWyJ0MDAxMCIsIDAuMzkwMzU1MzQxXSwgWyJ0MDAxMSIsIDAuMzk4MzY3Nzc4XSwgWyJ0MDAxMiIsIDAuMzkxNTU2Nzk3XSwgWyJ0MDAxMyIsIDAuNDAwNDUwODNdLCBbInQwMDE0IiwgMC4zOTczOTI1OTddLCBbInQwMDE1IiwgMC4zNDM0MzMwNjVdLCBbInQwMDE2IiwgMC4zNjc5NTg1OTldLCBbInQwMDE3IiwgMC4zODg1MTMwMDddLCBbInQwMDE4IiwgMC4zOTgzMDcyMzFdLCBbInQwMDE5IiwgMC40MzEwOTM1MTddLCBbInQwMDIwIiwgMC40NjE1NzUwMzZdLCBbInQwMDIxIiwgMC40NjA0NDYyNzddLCBbInQwMDIyIiwgMC40NDUyNzQ1ODFdLCBbInQwMDIzIiwgMC41Mzc4Mjg3MjRdLCBbInQwMDI0IiwgMC41MDA4ODMwODVdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDAyNSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDI1IiwgMC41NzAyMjExNDZdLCBbInQwMDI2IiwgMC41MTg3ODcyMTJdLCBbInQwMDI3IiwgMC40NTQxOTc3NDZdLCBbInQwMDI4IiwgMC4zNjI3MTk3NV0sIFsidDAwMjkiLCAwLjQwMTkzNTE5MV0sIFsidDAwMzAiLCAwLjM4MzM0MTYyMl0sIFsidDAwMzEiLCAwLjM5MjQ1MjgwNF0sIFsidDAwMzIiLCAwLjM3MDY5NDc2NF0sIFsidDAwMzMiLCAwLjM4MTc3OTM2XSwgWyJ0MDAzNCIsIDAuNDYzMjAyMTc5XSwgWyJ0MDAzNSIsIDAuNTI2ODE3OTI1XSwgWyJ0MDAzNiIsIDAuNTQ4ODA2MjU3XSwgWyJ0MDAzNyIsIDAuNTY0NzUxOTYzXSwgWyJ0MDAzOCIsIDAuNTY1OTgwMjI3XSwgWyJ0MDAzOSIsIDAuNTM3NDE3NDE1XSwgWyJ0MDA0MCIsIDAuNTg1MzI2MTI2XSwgWyJ0MDA0MSIsIDAuNTU1MzIwMTU1XSwgWyJ0MDA0MiIsIDAuNTEwMTkxOTY3XSwgWyJ0MDA0MyIsIDAuNDQwMjQwMjQ1XSwgWyJ0MDA0NCIsIDAuNDk5Mzk4OTEyXSwgWyJ0MDA0NSIsIDAuNDg4NjI3ODY1XSwgWyJ0MDA0NiIsIDAuNTQ0NDA4NTkzXSwgWyJ0MDA0NyIsIDAuNDcxNDM0NTM1XSwgWyJ0MDA0OCIsIDAuNTA5OTk4NTY4XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwNDkiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA0OSIsIDAuNDczNjgyMjk5XSwgWyJ0MDA1MCIsIDAuNDY3MDYxNjQ1XSwgWyJ0MDA1MSIsIDAuNDU3OTgwMDAxXSwgWyJ0MDA1MiIsIDAuNDYxNzM2MzM2XSwgWyJ0MDA1MyIsIDAuNDk4NzU0NTI4XSwgWyJ0MDA1NCIsIDAuNTUzNTQ1MjEzXSwgWyJ0MDA1NSIsIDAuNTA0MDQwNzldLCBbInQwMDU2IiwgMC41MzE0NDU2NDldLCBbInQwMDU3IiwgMC41NTYxNjk2ODZdLCBbInQwMDU4IiwgMC41Mzk0MDUzMTFdLCBbInQwMDU5IiwgMC41MzU0OTQ5NDVdLCBbInQwMDYwIiwgMC41MjQxNjQxNThdLCBbInQwMDYxIiwgMC40NDUyNTA1Nl0sIFsidDAwNjIiLCAwLjUwMjAyMTU5NV0sIFsidDAwNjMiLCAwLjQzOTQ3OTE0NF0sIFsidDAwNjQiLCAwLjQwNjcxMDg3OF0sIFsidDAwNjUiLCAwLjQ2NzE5MzYyXSwgWyJ0MDA2NiIsIDAuNDg4NzI1NjMxXSwgWyJ0MDA2NyIsIDAuNTM5NzY3NzY2XSwgWyJ0MDA2OCIsIDAuNTY2MjQxMTM0XSwgWyJ0MDA2OSIsIDAuNTYyMTE4OTc1XSwgWyJ0MDA3MCIsIDAuNTg1NTEyMDI0XSwgWyJ0MDA3MSIsIDAuNjI0MDkwNTRdLCBbInQwMDcyIiwgMC41NzA0MzQzODZdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDA3MyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDczIiwgMC41NjE5ODk4NTVdLCBbInQwMDc0IiwgMC41NjA3Njk0NTJdLCBbInQwMDc1IiwgMC40ODQ4ODAyMzZdLCBbInQwMDc2IiwgMC40ODQ1OTMwNDJdLCBbInQwMDc3IiwgMC40OTkyOTYxMTVdLCBbInQwMDc4IiwgMC41MzI3Nzc3ODddLCBbInQwMDc5IiwgMC42MjUyNDg5NF0sIFsidDAwODAiLCAwLjYyNjE3MjM3Nl0sIFsidDAwODEiLCAwLjYxOTQ3OTE1MV0sIFsidDAwODIiLCAwLjUxMDM4OTQ0Ml0sIFsidDAwODMiLCAwLjUzMTYwNTE2NF0sIFsidDAwODQiLCAwLjU4Njc3ODkzN10sIFsidDAwODUiLCAwLjU0OTgyMTEwMl0sIFsidDAwODYiLCAwLjU1MjY5ODIwNl0sIFsidDAwODciLCAwLjUyODI4ODE5NF0sIFsidDAwODgiLCAwLjU0OTkwNjkyOV0sIFsidDAwODkiLCAwLjU2MDYyNDAwMl0sIFsidDAwOTAiLCAwLjYwMzU4MTA2NV0sIFsidDAwOTEiLCAwLjU0MjY5Mzc5M10sIFsidDAwOTIiLCAwLjU1ODE0NjEwNl0sIFsidDAwOTMiLCAwLjU0MzAzNTI1OF0sIFsidDAwOTQiLCAwLjUzODU3MjM5XSwgWyJ0MDA5NSIsIDAuNDg0Njg3MjY0XSwgWyJ0MDA5NiIsIDAuNDc4NzAzNjQ3XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwOTciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA5NyIsIDAuNTQ0MDAwNzgyXSwgWyJ0MDA5OCIsIDAuNTEzODkzNTU4XSwgWyJ0MDA5OSIsIDAuNTQxNzU3Nzg1XSwgWyJ0MDEwMCIsIDAuNTEzOTYzMDMyXSwgWyJ0MDEwMSIsIDAuNTE5NzY2NTYyXSwgWyJ0MDEwMiIsIDAuNjEyMDg4NTI4XSwgWyJ0MDEwMyIsIDAuNjIwNDY4NDk3XSwgWyJ0MDEwNCIsIDAuNTU3MjM5MzY2XSwgWyJ0MDEwNSIsIDAuNTg3NzY4NzE5XSwgWyJ0MDEwNiIsIDAuNTcwNzgyNDM4XSwgWyJ0MDEwNyIsIDAuNTgxNjgwOTQzXSwgWyJ0MDEwOCIsIDAuNTc0ODEzMzEyXSwgWyJ0MDEwOSIsIDAuNTcwNDM2NjY1XSwgWyJ0MDExMCIsIDAuNTg2MTU0NTE4XSwgWyJ0MDExMSIsIDAuNTQ0NzM4NzI3XSwgWyJ0MDExMiIsIDAuNTMwMzMxNTUzXSwgWyJ0MDExMyIsIDAuNTIwMjgwMzE1XSwgWyJ0MDExNCIsIDAuNDUxMzE4MjU5XSwgWyJ0MDExNSIsIDAuNDIyNTMwNjhdLCBbInQwMTE2IiwgMC40NDgxMDk4MDZdLCBbInQwMTE3IiwgMC40ODczNTk0MjFdLCBbInQwMTE4IiwgMC40ODEyMjEzOTNdLCBbInQwMTE5IiwgMC41MzY3ODUyNTldLCBbInQwMTIwIiwgMC41MzIzNTMwMl1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTIxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxMjEiLCAwLjQ0OTUyODMxM10sIFsidDAxMjIiLCAwLjUyMjk4MjI0Ml0sIFsidDAxMjMiLCAwLjQ5NDQ3MjkxN10sIFsidDAxMjQiLCAwLjQ1MTkwNDMyNF0sIFsidDAxMjUiLCAwLjQ3Mzk3NDkxNV0sIFsidDAxMjYiLCAwLjQ3MDMyMTY1N10sIFsidDAxMjciLCAwLjUyMzI0OTI0Ml0sIFsidDAxMjgiLCAwLjUxMTk1Mzk0M10sIFsidDAxMjkiLCAwLjU2OTQ3MjQzXSwgWyJ0MDEzMCIsIDAuNTM3MDg1NjddLCBbInQwMTMxIiwgMC41ODAzMjQwMjVdLCBbInQwMTMyIiwgMC41NzM2NzA3MzldLCBbInQwMTMzIiwgMC41NjY5MTc5NTddLCBbInQwMTM0IiwgMC41NDc1MjYwNzddLCBbInQwMTM1IiwgMC42NDEwNjQwMzRdLCBbInQwMTM2IiwgMC42MzAwNTAxMDJdLCBbInQwMTM3IiwgMC41NTM0ODI1MDhdLCBbInQwMTM4IiwgMC41NDA3MzkyMTVdLCBbInQwMTM5IiwgMC41NzAxMDM0Ml0sIFsidDAxNDAiLCAwLjU0MTM5MjE1NV0sIFsidDAxNDEiLCAwLjQ4OTA5MjU3OV0sIFsidDAxNDIiLCAwLjQ1MjYxNzEyOF0sIFsidDAxNDMiLCAwLjQxNTYzMTM5OV0sIFsidDAxNDQiLCAwLjQxMDY1NDM0MV1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTQ1IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxNDUiLCAwLjUyMzkwNDgzN10sIFsidDAxNDYiLCAwLjQ1Njc1NjM5OV0sIFsidDAxNDciLCAwLjM3MzM1MDUxXSwgWyJ0MDE0OCIsIDAuMzEzMDM1ODUzXSwgWyJ0MDE0OSIsIDAuNDExMzA3NjE1XSwgWyJ0MDE1MCIsIDAuNDg3MDA3NzM2XSwgWyJ0MDE1MSIsIDAuNDIyOTA3MDYzXSwgWyJ0MDE1MiIsIDAuMzY5Mjk2Mzg3XSwgWyJ0MDE1MyIsIDAuMjg4NjI5NjM5XSwgWyJ0MDE1NCIsIDAuMzM4OTk1NDM5XSwgWyJ0MDE1NSIsIDAuMjY5MDA5NTM4XSwgWyJ0MDE1NiIsIDAuMzA4NjkyNDJdLCBbInQwMTU3IiwgMC4yOTk5MzUyMjFdLCBbInQwMTU4IiwgMC4zMzg4NzEzNTZdLCBbInQwMTU5IiwgMC4zMjQ5NDU1NTJdLCBbInQwMTYwIiwgMC4zMTgzODg1MjNdLCBbInQwMTYxIiwgMC4zMDExMzg1MzhdLCBbInQwMTYyIiwgMC4zNzQ1Njk4ODRdLCBbInQwMTYzIiwgMC40Mzk1Mjg3MDVdLCBbInQwMTY0IiwgMC40NDA5MDAxNjhdLCBbInQwMTY1IiwgMC40NzU3NDMxODNdLCBbInQwMTY2IiwgMC40NDk5MzIxNDJdLCBbInQwMTY3IiwgMC40MTg5MzE5NjhdLCBbInQwMTY4IiwgMC40NzU3MzM2ODFdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XV0sICJpbmRleF9uYW1lIjogImFuYWx5c2lzX3RpbWUifV0sIFsibWlkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuNDQxOTM5OTRdLCBbInQwMDAyIiwgMC40NTY5MDY1MDNdLCBbInQwMDAzIiwgMC41MzExMjQwNDFdLCBbInQwMDA0IiwgMC41NDU4ODU0ODFdLCBbInQwMDA1IiwgMC41MzUzNzkzOTZdLCBbInQwMDA2IiwgMC41NzQ0MjgyOTJdLCBbInQwMDA3IiwgMC41MTYyNTM1MzVdLCBbInQwMDA4IiwgMC40NTIwODYyMl0sIFsidDAwMDkiLCAwLjU1MTc4ODk4N10sIFsidDAwMTAiLCAwLjQ4NzIwNzI0N10sIFsidDAwMTEiLCAwLjUxOTE1MjYwN10sIFsidDAwMTIiLCAwLjQ5OTIwNjg3N10sIFsidDAwMTMiLCAwLjQ2MDI4NjU1MV0sIFsidDAwMTQiLCAwLjUxNTE3NTc0Nl0sIFsidDAwMTUiLCAwLjUxMzYxMDg3N10sIFsidDAwMTYiLCAwLjQ4MDI3MDAwM10sIFsidDAwMTciLCAwLjQ4NzIyOTEwNl0sIFsidDAwMTgiLCAwLjQ4NDg5Mzg5Ml0sIFsidDAwMTkiLCAwLjUxMDAwNTgyNV0sIFsidDAwMjAiLCAwLjQ5NDY4MTE3OF0sIFsidDAwMjEiLCAwLjUwMTI0MTY1OV0sIFsidDAwMjIiLCAwLjQ5NDEwNTQ0OV0sIFsidDAwMjMiLCAwLjUwNjc1MzAxOV0sIFsidDAwMjQiLCAwLjUwMDkxOTcxM11dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMDI1IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMjUiLCAwLjQ0NDQ2NjUzM10sIFsidDAwMjYiLCAwLjUxODUwOTE0XSwgWyJ0MDAyNyIsIDAuNTU0NTczNjI3XSwgWyJ0MDAyOCIsIDAuNTI1MDgzNjA4XSwgWyJ0MDAyOSIsIDAuNDU3NDE1NDk3XSwgWyJ0MDAzMCIsIDAuNDc5NDE5MTg2XSwgWyJ0MDAzMSIsIDAuNDk2MjY2MDU4XSwgWyJ0MDAzMiIsIDAuNTcxMDM1MTA2XSwgWyJ0MDAzMyIsIDAuNDM2Njg2MTU1XSwgWyJ0MDAzNCIsIDAuNTA3MjU0NjA2XSwgWyJ0MDAzNSIsIDAuNTE0NjA3Mjc2XSwgWyJ0MDAzNiIsIDAuNDU5NzE2OTM5XSwgWyJ0MDAzNyIsIDAuNTQxMTMwNDFdLCBbInQwMDM4IiwgMC41NDE0NTU3MTRdLCBbInQwMDM5IiwgMC40NTA5MDUzNTJdLCBbInQwMDQwIiwgMC40ODQzNTAxOTNdLCBbInQwMDQxIiwgMC40NzU0ODQyMjhdLCBbInQwMDQyIiwgMC40ODA1MjkzOThdLCBbInQwMDQzIiwgMC40MTI1MzgwOTJdLCBbInQwMDQ0IiwgMC41Mjc0MjM4MzRdLCBbInQwMDQ1IiwgMC41MzEzODIwOTRdLCBbInQwMDQ2IiwgMC41MjQ2MDkzNjZdLCBbInQwMDQ3IiwgMC40OTE2MTUyNzldLCBbInQwMDQ4IiwgMC40NDk0NjU2MDddLCBbInQwMDQ5IiwgMC40ODM4MTI5ODZdLCBbInQwMDUwIiwgMC40OTYzMTQ1NzVdLCBbInQwMDUxIiwgMC40NjE2OTkxMzldLCBbInQwMDUyIiwgMC41MDMzNjA0MDVdLCBbInQwMDUzIiwgMC41NTQ3MzQ3OTddLCBbInQwMDU0IiwgMC41NTAzNTU1MV0sIFsidDAwNTUiLCAwLjQ0NzE0MDg3N10sIFsidDAwNTYiLCAwLjQ5OTQ5NTYwM10sIFsidDAwNTciLCAwLjUwNTQxOTY0M10sIFsidDAwNTgiLCAwLjQ4MDk4Mzc0NF0sIFsidDAwNTkiLCAwLjQ4MjcxMzMzOV0sIFsidDAwNjAiLCAwLjQ5NzY4MTI3OF0sIFsidDAwNjEiLCAwLjQ5NDIzMzU4Nl0sIFsidDAwNjIiLCAwLjQ2Mzk1NDI2NV0sIFsidDAwNjMiLCAwLjUwMTU4OTEwNl0sIFsidDAwNjQiLCAwLjQ5NjY1MDddLCBbInQwMDY1IiwgMC40OTUyMDAwNTNdLCBbInQwMDY2IiwgMC41MzcyNTEzMDRdLCBbInQwMDY3IiwgMC41MTgwNTk2MV0sIFsidDAwNjgiLCAwLjQ4NzU1MTk0N10sIFsidDAwNjkiLCAwLjUwMDIyMzcyMV0sIFsidDAwNzAiLCAwLjU0NzUzODI0OV0sIFsidDAwNzEiLCAwLjQ4MTU0NTY5OV0sIFsidDAwNzIiLCAwLjQ1NDAwNjAzOV0sIFsidDAwNzMiLCAwLjU1NzYyNDE2Nl0sIFsidDAwNzQiLCAwLjUwMDQxOTQ2NF0sIFsidDAwNzUiLCAwLjQ5OTE2NDMzM10sIFsidDAwNzYiLCAwLjQ1MzE3NjA0N10sIFsidDAwNzciLCAwLjUzOTk3Mjc2MV0sIFsidDAwNzgiLCAwLjUyMjkxNDc3M10sIFsidDAwNzkiLCAwLjQxODgwNTMzNV0sIFsidDAwODAiLCAwLjUzOTc1MjI5M10sIFsidDAwODEiLCAwLjQ5NjIyOTAyXSwgWyJ0MDA4MiIsIDAuNDcwODk1NjAxXSwgWyJ0MDA4MyIsIDAuNTM1NTI4MzExXSwgWyJ0MDA4NCIsIDAuNDY4MjM1NjQxXSwgWyJ0MDA4NSIsIDAuNTgyODE4OTAyXSwgWyJ0MDA4NiIsIDAuNDc3OTkzOTVdLCBbInQwMDg3IiwgMC40Njg0MTM5OTNdLCBbInQwMDg4IiwgMC40NDQwMjUwNDhdLCBbInQwMDg5IiwgMC40NzY4OTQyOTldLCBbInQwMDkwIiwgMC40ODc5MTk1ODVdLCBbInQwMDkxIiwgMC40Mzk1OTAxMjddLCBbInQwMDkyIiwgMC40Mzc3MDAwMjJdLCBbInQwMDkzIiwgMC41NTA3NTY2MjddLCBbInQwMDk0IiwgMC41MDgyNjczNTldLCBbInQwMDk1IiwgMC40ODc0NzM2OTJdLCBbInQwMDk2IiwgMC41MDg5NzI0MTZdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDA0OSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDQ5IiwgMC40NzIxMzMzMzVdLCBbInQwMDUwIiwgMC40OTMwMzY4NzhdLCBbInQwMDUxIiwgMC40Nzk1Njk2NTldLCBbInQwMDUyIiwgMC41MTU3NTY1NDJdLCBbInQwMDUzIiwgMC41MTU1MzQ4NjhdLCBbInQwMDU0IiwgMC40OTExMDg5M10sIFsidDAwNTUiLCAwLjUyNzA2NjQ4NF0sIFsidDAwNTYiLCAwLjQ3MzM1MDMxOV0sIFsidDAwNTciLCAwLjQwNzM2MjEwNl0sIFsidDAwNTgiLCAwLjU0OTU4MjUyNF0sIFsidDAwNTkiLCAwLjU1MzkyODkxMl0sIFsidDAwNjAiLCAwLjUzOTc2Nzg2NF0sIFsidDAwNjEiLCAwLjQ0Mjg5MzI2XSwgWyJ0MDA2MiIsIDAuNDk1ODIzMDldLCBbInQwMDYzIiwgMC40NzgyOTIxODNdLCBbInQwMDY0IiwgMC41MTI0NzA0NzZdLCBbInQwMDY1IiwgMC41NDMyODA1M10sIFsidDAwNjYiLCAwLjQzNDIwNDE2Nl0sIFsidDAwNjciLCAwLjQ4NjA1NDk0Nl0sIFsidDAwNjgiLCAwLjUwODM1ODE3OV0sIFsidDAwNjkiLCAwLjUyMzc5ODIyMV0sIFsidDAwNzAiLCAwLjUzNzgzMjMwM10sIFsidDAwNzEiLCAwLjQ2MzM3OTcxOF0sIFsidDAwNzIiLCAwLjQ2MjAxMTYxOF0sIFsidDAwNzMiLCAwLjQ2ODA1NjkzMV0sIFsidDAwNzQiLCAwLjQzNTY2MTIzNV0sIFsidDAwNzUiLCAwLjUyMjMyODc0NV0sIFsidDAwNzYiLCAwLjQ0NTcwODJdLCBbInQwMDc3IiwgMC40NTkwNDIyODJdLCBbInQwMDc4IiwgMC40Nzk4OTY1NjVdLCBbInQwMDc5IiwgMC40NjQ2MTY0ODhdLCBbInQwMDgwIiwgMC41NTQ3MjEwOTldLCBbInQwMDgxIiwgMC41MDQyOTk3NDldLCBbInQwMDgyIiwgMC40OTYzNjU5NzJdLCBbInQwMDgzIiwgMC41Mzg0NTc5ODNdLCBbInQwMDg0IiwgMC41NjMyMjMzNDNdLCBbInQwMDg1IiwgMC41MDcxOTU4NzZdLCBbInQwMDg2IiwgMC41ODY4NDI3NzZdLCBbInQwMDg3IiwgMC40OTQ1NTAwOTVdLCBbInQwMDg4IiwgMC41MjUxNjczNTJdLCBbInQwMDg5IiwgMC40Nzc4Mjc2MjZdLCBbInQwMDkwIiwgMC40NzU5MjU0OTZdLCBbInQwMDkxIiwgMC41NDA3MDcyNDVdLCBbInQwMDkyIiwgMC40NjExNjAzOTNdLCBbInQwMDkzIiwgMC41MTM4MDM4MDldLCBbInQwMDk0IiwgMC40NzQ4NTg4ODZdLCBbInQwMDk1IiwgMC41MTE5MDc2MjFdLCBbInQwMDk2IiwgMC41NTEwNDY0XSwgWyJ0MDA5NyIsIDAuNDgzMjM2NjExXSwgWyJ0MDA5OCIsIDAuNTA0MzM5Nzg5XSwgWyJ0MDA5OSIsIDAuNTg1MzQzMDMxXSwgWyJ0MDEwMCIsIDAuNTUyOTgzMTAyXSwgWyJ0MDEwMSIsIDAuNDU4ODQ1ODk0XSwgWyJ0MDEwMiIsIDAuNDc0MDIxMTYzXSwgWyJ0MDEwMyIsIDAuNTMzNzEzNzVdLCBbInQwMTA0IiwgMC41MTg0NTE3NTddLCBbInQwMTA1IiwgMC40Njk3MTU4MTRdLCBbInQwMTA2IiwgMC40NjQxNDAxN10sIFsidDAxMDciLCAwLjQzMjIxMDE3OV0sIFsidDAxMDgiLCAwLjQ2MjI5NDExM10sIFsidDAxMDkiLCAwLjUwNjMxODU2OV0sIFsidDAxMTAiLCAwLjQ3MTc3NTQ4NF0sIFsidDAxMTEiLCAwLjQ5MzY2OTQ2OF0sIFsidDAxMTIiLCAwLjQwODQzNzA5N10sIFsidDAxMTMiLCAwLjQ3NzM4OTI1NF0sIFsidDAxMTQiLCAwLjQ5MzMyMjE5OF0sIFsidDAxMTUiLCAwLjQzOTMxMzI4N10sIFsidDAxMTYiLCAwLjUxMDYwMzA4M10sIFsidDAxMTciLCAwLjU2MTcwNjI0MV0sIFsidDAxMTgiLCAwLjUyMjY2ODkxNV0sIFsidDAxMTkiLCAwLjQ4MzY5MzI5NF0sIFsidDAxMjAiLCAwLjUxMjY2NDI3M11dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMDczIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwNzMiLCAwLjQ0MTM5MTAzM10sIFsidDAwNzQiLCAwLjQxNzAzNDk1Ml0sIFsidDAwNzUiLCAwLjQ3ODQxNjUzN10sIFsidDAwNzYiLCAwLjQ5MTU3Njk2MV0sIFsidDAwNzciLCAwLjUwNzUwMDMwOF0sIFsidDAwNzgiLCAwLjU0MjIxNDE1OF0sIFsidDAwNzkiLCAwLjQzNTU3MDcwM10sIFsidDAwODAiLCAwLjUwOTk0MzYzOF0sIFsidDAwODEiLCAwLjU5NjIwNzgyNV0sIFsidDAwODIiLCAwLjUxNTU5MTM1M10sIFsidDAwODMiLCAwLjQ2ODg0NTE2N10sIFsidDAwODQiLCAwLjUzMjE3MzEyOV0sIFsidDAwODUiLCAwLjUzNjY2OTE1OF0sIFsidDAwODYiLCAwLjUyMzM2NzIxNl0sIFsidDAwODciLCAwLjUzMDM1Njc2Ml0sIFsidDAwODgiLCAwLjUwMzIyMTIxXSwgWyJ0MDA4OSIsIDAuNDc2MjEyMTIyXSwgWyJ0MDA5MCIsIDAuNDYyNDk4OTc1XSwgWyJ0MDA5MSIsIDAuNTYwOTk3MjQ3XSwgWyJ0MDA5MiIsIDAuNTIyMTgzMjMzXSwgWyJ0MDA5MyIsIDAuNTM4NDQxNjk2XSwgWyJ0MDA5NCIsIDAuNTA4NDk3MTldLCBbInQwMDk1IiwgMC40ODI0OTgwNzVdLCBbInQwMDk2IiwgMC40NzgyNjIzOTRdLCBbInQwMDk3IiwgMC41MDIxMDgyNTddLCBbInQwMDk4IiwgMC41MzMwMTE4MTZdLCBbInQwMDk5IiwgMC41NzM0NDE4Nl0sIFsidDAxMDAiLCAwLjQzNDMxNzUxOF0sIFsidDAxMDEiLCAwLjQ3NjI5MzMyXSwgWyJ0MDEwMiIsIDAuNDQ2NTI2MDAzXSwgWyJ0MDEwMyIsIDAuNDQ4Njg0Nzc3XSwgWyJ0MDEwNCIsIDAuNDcwNzc0OTk5XSwgWyJ0MDEwNSIsIDAuNTM2OTM5MTY4XSwgWyJ0MDEwNiIsIDAuNDc5MTIwNjMzXSwgWyJ0MDEwNyIsIDAuNTYzMzIwMzFdLCBbInQwMTA4IiwgMC40ODA2MzkwMjhdLCBbInQwMTA5IiwgMC40ODU2NzE4Ml0sIFsidDAxMTAiLCAwLjQyOTIyMzIyMl0sIFsidDAxMTEiLCAwLjUwOTI5MzAxMV0sIFsidDAxMTIiLCAwLjQ3MzQwNjQ2XSwgWyJ0MDExMyIsIDAuNTg1MDg2MDQ2XSwgWyJ0MDExNCIsIDAuNDkxMjA0NjQ3XSwgWyJ0MDExNSIsIDAuNDQxNDE1ODQ2XSwgWyJ0MDExNiIsIDAuNDg4NDQ2NjkzXSwgWyJ0MDExNyIsIDAuNDM2Mzc4NDM1XSwgWyJ0MDExOCIsIDAuNDQ1MDc0OTc2XSwgWyJ0MDExOSIsIDAuNTIwMTI4MTc2XSwgWyJ0MDEyMCIsIDAuNTAxMjczMzg3XSwgWyJ0MDEyMSIsIDAuNTA3MDczNjY5XSwgWyJ0MDEyMiIsIDAuNTA4OTA0NzhdLCBbInQwMTIzIiwgMC41MTg3MjMwMjVdLCBbInQwMTI0IiwgMC41MDQwMjI0NTZdLCBbInQwMTI1IiwgMC41NDUzNTM4NV0sIFsidDAxMjYiLCAwLjUxNzQyOTkyMV0sIFsidDAxMjciLCAwLjUyNDc2MDcxNF0sIFsidDAxMjgiLCAwLjQ2NjAwNTI5OV0sIFsidDAxMjkiLCAwLjQ1MzE1Mzg2MV0sIFsidDAxMzAiLCAwLjUzMDYwMDU1OV0sIFsidDAxMzEiLCAwLjUzMzM1MjczMV0sIFsidDAxMzIiLCAwLjUwNDUwMjc0N10sIFsidDAxMzMiLCAwLjUwMzQ4ODAzN10sIFsidDAxMzQiLCAwLjUyNDYzOTgzN10sIFsidDAxMzUiLCAwLjQ2ODM0MzE5MV0sIFsidDAxMzYiLCAwLjQ3ODQzNzQyNl0sIFsidDAxMzciLCAwLjUzNjk4MzU3OF0sIFsidDAxMzgiLCAwLjUxMzkwOTQxM10sIFsidDAxMzkiLCAwLjU1NjQxOTMwMV0sIFsidDAxNDAiLCAwLjQ5NDk1OTE0OF0sIFsidDAxNDEiLCAwLjU1MDIxMzE3OF0sIFsidDAxNDIiLCAwLjQ4Njg4NTE3OF0sIFsidDAxNDMiLCAwLjQ5NDQxODcyXSwgWyJ0MDE0NCIsIDAuNTAyMzE5MTk3XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwOTciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA5NyIsIDAuNDkyODU4NTI5XSwgWyJ0MDA5OCIsIDAuNDQ0ODM4NDVdLCBbInQwMDk5IiwgMC40MzE1NjI2MTNdLCBbInQwMTAwIiwgMC41MDk4NzI3NzFdLCBbInQwMTAxIiwgMC41MDExMjU2NjNdLCBbInQwMTAyIiwgMC41NzAxNjE3NzNdLCBbInQwMTAzIiwgMC40OTMwNzc2ODFdLCBbInQwMTA0IiwgMC41MzY3MDAwMV0sIFsidDAxMDUiLCAwLjQ3Mzc3MTk4OF0sIFsidDAxMDYiLCAwLjUwMzcyNzY3NV0sIFsidDAxMDciLCAwLjU0NjcxNjY4MV0sIFsidDAxMDgiLCAwLjUzMjQwNzYwOV0sIFsidDAxMDkiLCAwLjUzMjM3NTAxMV0sIFsidDAxMTAiLCAwLjQ5OTQzMjYyNF0sIFsidDAxMTEiLCAwLjQxNjcwMTkxMl0sIFsidDAxMTIiLCAwLjQ3OTgxMjM3N10sIFsidDAxMTMiLCAwLjUwMzAyNzM2Nl0sIFsidDAxMTQiLCAwLjQ0MjIzMjg0Nl0sIFsidDAxMTUiLCAwLjQ0MDc0NjYwNl0sIFsidDAxMTYiLCAwLjUzNTE4MjI3OV0sIFsidDAxMTciLCAwLjQ4MjEzMTM1NF0sIFsidDAxMTgiLCAwLjUzMDUxMzcxOV0sIFsidDAxMTkiLCAwLjQ0Mjc0MDAwOV0sIFsidDAxMjAiLCAwLjQzMDQ2Njg0Nl0sIFsidDAxMjEiLCAwLjQ3NzIyOTA5N10sIFsidDAxMjIiLCAwLjQ2NDE3NzgxMl0sIFsidDAxMjMiLCAwLjQ0NDM4NDc5MV0sIFsidDAxMjQiLCAwLjU1MTUxMjE5NV0sIFsidDAxMjUiLCAwLjQ3MjAxMDg4Ml0sIFsidDAxMjYiLCAwLjU0Njk0Njc1NV0sIFsidDAxMjciLCAwLjQ3OTU1MjIzOV0sIFsidDAxMjgiLCAwLjQ3Njc1OTMxN10sIFsidDAxMjkiLCAwLjUwMTU0NDQ2MV0sIFsidDAxMzAiLCAwLjQ0Njc0MDEzM10sIFsidDAxMzEiLCAwLjU1MjE1NTMzN10sIFsidDAxMzIiLCAwLjQ5ODM1NzQ0NV0sIFsidDAxMzMiLCAwLjQ1MjE3NjU1OF0sIFsidDAxMzQiLCAwLjU1MzYxMDA5OF0sIFsidDAxMzUiLCAwLjQ5MzUyMTc1NV0sIFsidDAxMzYiLCAwLjU0MTMzODIwNF0sIFsidDAxMzciLCAwLjUwNzA5MDAxOV0sIFsidDAxMzgiLCAwLjUxNDQwMzExNl0sIFsidDAxMzkiLCAwLjUwODI4MDg2Ml0sIFsidDAxNDAiLCAwLjQ0MzU0NTAwNl0sIFsidDAxNDEiLCAwLjUxMzE2MzU2XSwgWyJ0MDE0MiIsIDAuNTQyMDM4ODExXSwgWyJ0MDE0MyIsIDAuNTIxOTYyMzM0XSwgWyJ0MDE0NCIsIDAuNDcxNjU2ODExXSwgWyJ0MDE0NSIsIDAuNDY5NDA2NzIyXSwgWyJ0MDE0NiIsIDAuNDk4Mjk3NDk0XSwgWyJ0MDE0NyIsIDAuNTU2MjQ1NTY1XSwgWyJ0MDE0OCIsIDAuNDcyMjgxMTJdLCBbInQwMTQ5IiwgMC41NDkyNTQ4MThdLCBbInQwMTUwIiwgMC40ODQ0MTIzOTZdLCBbInQwMTUxIiwgMC40OTI2NTA4NDRdLCBbInQwMTUyIiwgMC41NTQ2MDcwOF0sIFsidDAxNTMiLCAwLjUzMDUwMDE5NV0sIFsidDAxNTQiLCAwLjQ3NzY1NTU5OF0sIFsidDAxNTUiLCAwLjQ5NDMwODU4Nl0sIFsidDAxNTYiLCAwLjQyNDUxNzc4NF0sIFsidDAxNTciLCAwLjU4OTgzNjE3Ml0sIFsidDAxNTgiLCAwLjUwMTc0ODE3OF0sIFsidDAxNTkiLCAwLjUzOTg3OTk2NV0sIFsidDAxNjAiLCAwLjQyODc3ODEwM10sIFsidDAxNjEiLCAwLjUxOTQ2Nzg1NF0sIFsidDAxNjIiLCAwLjQ5ODc3ODI3M10sIFsidDAxNjMiLCAwLjQyOTUyMTEzOF0sIFsidDAxNjQiLCAwLjQ2MTU0NTQ0M10sIFsidDAxNjUiLCAwLjQ4MDczNjYyM10sIFsidDAxNjYiLCAwLjQ2MzA2NjUzOV0sIFsidDAxNjciLCAwLjU0NjUzNzcyN10sIFsidDAxNjgiLCAwLjQ3NDQ5NDMxNV1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTIxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxMjEiLCAwLjU2MjgwNjI4XSwgWyJ0MDEyMiIsIDAuNTIxMDMwMzFdLCBbInQwMTIzIiwgMC41NTcyNDYyNTNdLCBbInQwMTI0IiwgMC41MTk5NTgxNDFdLCBbInQwMTI1IiwgMC41MDExNDQyMDNdLCBbInQwMTI2IiwgMC40OTM5MTYxODJdLCBbInQwMTI3IiwgMC41NzI3MjQ2NzNdLCBbInQwMTI4IiwgMC40NjY0NTk3NV0sIFsidDAxMjkiLCAwLjQ2NDUxMTQ2N10sIFsidDAxMzAiLCAwLjU4MTY3NzgzMl0sIFsidDAxMzEiLCAwLjUyNzEwMjkxMV0sIFsidDAxMzIiLCAwLjQ5NDQ0NDA5Nl0sIFsidDAxMzMiLCAwLjUzNDUyNDA4OF0sIFsidDAxMzQiLCAwLjQ3MzM3NDcyM10sIFsidDAxMzUiLCAwLjQ4MTA0NDMxNV0sIFsidDAxMzYiLCAwLjQ2MTc5NDUzN10sIFsidDAxMzciLCAwLjQ5MDMwMjE3Nl0sIFsidDAxMzgiLCAwLjQ2NzIzMzkyOV0sIFsidDAxMzkiLCAwLjUyMTUyNjAwMl0sIFsidDAxNDAiLCAwLjU1NDQ4OTQzOF0sIFsidDAxNDEiLCAwLjU0MTEyMDE0OF0sIFsidDAxNDIiLCAwLjQ2MzMzODU4OF0sIFsidDAxNDMiLCAwLjUxMjA4MjY0N10sIFsidDAxNDQiLCAwLjQ3NDg5ODI0OF1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTQ1IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxNDUiLCAwLjU1MzM0NDQzNV0sIFsidDAxNDYiLCAwLjUyNjcxMjgzXSwgWyJ0MDE0NyIsIDAuNDgxMzc2NTRdLCBbInQwMTQ4IiwgMC41MTA3NTE1MDRdLCBbInQwMTQ5IiwgMC40MzcwOTg4NDhdLCBbInQwMTUwIiwgMC41MTAxMTI3MTRdLCBbInQwMTUxIiwgMC41MzEyMTgxNzFdLCBbInQwMTUyIiwgMC40NTk2NjIyNzRdLCBbInQwMTUzIiwgMC41MDA2ODU5NjJdLCBbInQwMTU0IiwgMC40NTYxNjA4ODZdLCBbInQwMTU1IiwgMC41MDQ3ODQ5MDNdLCBbInQwMTU2IiwgMC40OTg4MDQ5MzJdLCBbInQwMTU3IiwgMC41MjAwMjA1NjRdLCBbInQwMTU4IiwgMC40NjE4Njk5ODZdLCBbInQwMTU5IiwgMC40OTYzOTI3NDhdLCBbInQwMTYwIiwgMC41MDg2MTY5NTNdLCBbInQwMTYxIiwgMC40ODQ0NTQ3MjNdLCBbInQwMTYyIiwgMC40NTM5NzE1MzddLCBbInQwMTYzIiwgMC40Njg1NzIxNTddLCBbInQwMTY0IiwgMC40NzUwOTMwNDJdLCBbInQwMTY1IiwgMC40OTU4NTcyNjhdLCBbInQwMTY2IiwgMC40OTUxMDA5OTNdLCBbInQwMTY3IiwgMC41NTE1MDQwNDJdLCBbInQwMTY4IiwgMC40OTY1NDU5NTVdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XV0sICJpbmRleF9uYW1lIjogImFuYWx5c2lzX3RpbWUifV0sIFsidXBwZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC45MTk1OTIwNTJdLCBbInQwMDAyIiwgMC45NzM3ODM3MTddLCBbInQwMDAzIiwgMC44NzEyMzEwNDFdLCBbInQwMDA0IiwgMC45MzMwNDA2NDFdLCBbInQwMDA1IiwgMC44OTY4MzAxODhdLCBbInQwMDA2IiwgMC44ODU0NTE3NzFdLCBbInQwMDA3IiwgMC45MjQ0OTE3MjNdLCBbInQwMDA4IiwgMC45MDA1NjYwNTddLCBbInQwMDA5IiwgMC44NjU5MDYxOThdLCBbInQwMDEwIiwgMC45Mjk2NTE4NDVdLCBbInQwMDExIiwgMC44NjA2MTUzMDVdLCBbInQwMDEyIiwgMC45MDQ3NTMxMzRdLCBbInQwMDEzIiwgMC45MzI0MTI2MDddLCBbInQwMDE0IiwgMC45NTcwNjY4MThdLCBbInQwMDE1IiwgMC45NDUwNDU1NzldLCBbInQwMDE2IiwgMC44ODk1NTE1ODNdLCBbInQwMDE3IiwgMC45MDc0OTkwNTNdLCBbInQwMDE4IiwgMC45MTg5MDk0MDZdLCBbInQwMDE5IiwgMC44Nzg1ODQ5MzldLCBbInQwMDIwIiwgMC44NjM1OTA4MDVdLCBbInQwMDIxIiwgMC45MDY3NDM4NThdLCBbInQwMDIyIiwgMC45MjMzNjUwODFdLCBbInQwMDIzIiwgMC44OTQxNDY0MTldLCBbInQwMDI0IiwgMC44OTMxMTcyODhdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDAyNSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDI1IiwgMC45MDQyMzE1OF0sIFsidDAwMjYiLCAwLjkwMzQ5ODk0OF0sIFsidDAwMjciLCAwLjg3Mzg5ODkzNV0sIFsidDAwMjgiLCAwLjk2ODA5NTY1Nl0sIFsidDAwMjkiLCAwLjg3NjgxODc4OV0sIFsidDAwMzAiLCAwLjg5MDQ0MDA5XSwgWyJ0MDAzMSIsIDAuODk0Mjg2MjY2XSwgWyJ0MDAzMiIsIDAuOTAyNDcxNzY0XSwgWyJ0MDAzMyIsIDAuODQ0MTk3NTExXSwgWyJ0MDAzNCIsIDAuOTMxMzc2MjUyXSwgWyJ0MDAzNSIsIDAuODYxMjkxNTEzXSwgWyJ0MDAzNiIsIDAuODgyMzU5NDU1XSwgWyJ0MDAzNyIsIDAuODcyODU2ODAyXSwgWyJ0MDAzOCIsIDAuOTUzNjAzNTZdLCBbInQwMDM5IiwgMC44NzM5NzIxODldLCBbInQwMDQwIiwgMC44ODA3Mjg3NzhdLCBbInQwMDQxIiwgMC45NTg5MzcyOTFdLCBbInQwMDQyIiwgMC45NTI4ODc0OTNdLCBbInQwMDQzIiwgMC45MjQzNDM5NDVdLCBbInQwMDQ0IiwgMC45MTM0NzA0ODVdLCBbInQwMDQ1IiwgMC45MzAyOTU0NzVdLCBbInQwMDQ2IiwgMC44ODU3OTQ1MjldLCBbInQwMDQ3IiwgMC44ODk2MzQyNzZdLCBbInQwMDQ4IiwgMC45MDUwMzczMTNdLCBbInQwMDQ5IiwgMC44NTEyNjM2MDRdLCBbInQwMDUwIiwgMC44Mjg4MDE4MV0sIFsidDAwNTEiLCAwLjkzMjI2NDQ1XSwgWyJ0MDA1MiIsIDAuODI5NTYyMjA5XSwgWyJ0MDA1MyIsIDAuOTI4MzI1ODc2XSwgWyJ0MDA1NCIsIDAuOTEwNTU2OTE2XSwgWyJ0MDA1NSIsIDAuODYzNjEyNjA0XSwgWyJ0MDA1NiIsIDAuOTE3MDgyNTg3XSwgWyJ0MDA1NyIsIDAuODA0ODkzMzc0XSwgWyJ0MDA1OCIsIDAuOTIzNjY3MTY2XSwgWyJ0MDA1OSIsIDAuODkyMjUzNzY4XSwgWyJ0MDA2MCIsIDAuOTY5Nzg0MjQ4XSwgWyJ0MDA2MSIsIDAuOTI4NDI1NjQyXSwgWyJ0MDA2MiIsIDAuODcwOTI1OTNdLCBbInQwMDYzIiwgMC44NzAzOTI2MjNdLCBbInQwMDY0IiwgMC45MDExNjc2MTFdLCBbInQwMDY1IiwgMC44NzkxNTMzMl0sIFsidDAwNjYiLCAwLjg4NDg2NDc0N10sIFsidDAwNjciLCAwLjg3MTI5OTYzN10sIFsidDAwNjgiLCAwLjg2ODI4OTUwMV0sIFsidDAwNjkiLCAwLjg3MTExODI1XSwgWyJ0MDA3MCIsIDAuOTQwNTAzNTk1XSwgWyJ0MDA3MSIsIDAuOTU5MTI1NjMyXSwgWyJ0MDA3MiIsIDAuOTA0NjY1MzkyXSwgWyJ0MDA3MyIsIDAuOTM0ODY4NjM2XSwgWyJ0MDA3NCIsIDAuOTMwNTU1MTY2XSwgWyJ0MDA3NSIsIDAuODU5MzI3ODgxXSwgWyJ0MDA3NiIsIDAuODY5MDEzNDM4XSwgWyJ0MDA3NyIsIDAuOTEwMTgxNjM5XSwgWyJ0MDA3OCIsIDAuODQ5MTcwMjgyXSwgWyJ0MDA3OSIsIDAuOTMzNjAzNDM2XSwgWyJ0MDA4MCIsIDAuODc3ODUzMTcyXSwgWyJ0MDA4MSIsIDAuOTQwNjg1ODM1XSwgWyJ0MDA4MiIsIDAuOTM2NzU1OTgyXSwgWyJ0MDA4MyIsIDAuOTM1Mjk1MjE5XSwgWyJ0MDA4NCIsIDAuODQxODU0NTQxXSwgWyJ0MDA4NSIsIDAuODUxNTk3MjU1XSwgWyJ0MDA4NiIsIDAuODY0NDUyOTJdLCBbInQwMDg3IiwgMC45NDI1NTc0NDJdLCBbInQwMDg4IiwgMC45MzU0OTg5MDJdLCBbInQwMDg5IiwgMC45MzQxMzU5OV0sIFsidDAwOTAiLCAwLjgzMTIyOTc5OV0sIFsidDAwOTEiLCAwLjk4MzgxNjQwNl0sIFsidDAwOTIiLCAwLjg3MTc0NDkyNl0sIFsidDAwOTMiLCAwLjkxMTUwNzk1MV0sIFsidDAwOTQiLCAwLjk1NDU2NjQ3OV0sIFsidDAwOTUiLCAwLjgzNjUyOTUwNV0sIFsidDAwOTYiLCAwLjgzODExMzA0XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwNDkiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA0OSIsIDAuODE4ODAxMTM3XSwgWyJ0MDA1MCIsIDAuOTI4OTk5MzYyXSwgWyJ0MDA1MSIsIDAuOTM4MDM0MDQxXSwgWyJ0MDA1MiIsIDAuOTAxMzg2NjkxXSwgWyJ0MDA1MyIsIDAuOTE5MTI5MzExXSwgWyJ0MDA1NCIsIDAuOTE1MjA1Nzk3XSwgWyJ0MDA1NSIsIDAuODI2MzYwMzE3XSwgWyJ0MDA1NiIsIDAuOTA1NzI3NzQ0XSwgWyJ0MDA1NyIsIDAuOTE5MjM5NjQ1XSwgWyJ0MDA1OCIsIDAuODU4NTk4NDc0XSwgWyJ0MDA1OSIsIDAuODY5MjY0MTEzXSwgWyJ0MDA2MCIsIDAuOTYwMTQ0NzY5XSwgWyJ0MDA2MSIsIDAuODkzMDc3NDQ4XSwgWyJ0MDA2MiIsIDAuODc5NTQwNV0sIFsidDAwNjMiLCAwLjg2NDMwMTgwOF0sIFsidDAwNjQiLCAwLjkzNDAxNjQ2Nl0sIFsidDAwNjUiLCAwLjkxMTIzOTQ0XSwgWyJ0MDA2NiIsIDAuODY0NDk4Mjc5XSwgWyJ0MDA2NyIsIDAuOTAzNjU1NTQyXSwgWyJ0MDA2OCIsIDAuOTY1MjA0OTU0XSwgWyJ0MDA2OSIsIDAuOTE0NzM0MDI5XSwgWyJ0MDA3MCIsIDAuODQwNTgzODI1XSwgWyJ0MDA3MSIsIDAuODcwMjQ2NDQ1XSwgWyJ0MDA3MiIsIDAuODI5ODAzNjg3XSwgWyJ0MDA3MyIsIDAuODM1MjU1NjM4XSwgWyJ0MDA3NCIsIDAuOTAzMDkzMzY2XSwgWyJ0MDA3NSIsIDAuOTE1NjAxNTI0XSwgWyJ0MDA3NiIsIDAuODk0NDgyMTY4XSwgWyJ0MDA3NyIsIDAuODAxOTExMzQ3XSwgWyJ0MDA3OCIsIDAuOTUwNjQyOTgyXSwgWyJ0MDA3OSIsIDAuOTU1ODkwMTQyXSwgWyJ0MDA4MCIsIDAuODM5NDE0ODU4XSwgWyJ0MDA4MSIsIDAuOTEyMDgxNDQyXSwgWyJ0MDA4MiIsIDAuODQ4Nzk2MDkyXSwgWyJ0MDA4MyIsIDAuODQ0MDg1MzUxXSwgWyJ0MDA4NCIsIDAuOTQzOTg3NDE3XSwgWyJ0MDA4NSIsIDAuOTM4MjQ1Mjc2XSwgWyJ0MDA4NiIsIDAuODE1Mjk5OTMzXSwgWyJ0MDA4NyIsIDAuOTM2NjM5MDZdLCBbInQwMDg4IiwgMC45NDE3MjIzMThdLCBbInQwMDg5IiwgMC44OTYwNzUwNDNdLCBbInQwMDkwIiwgMC45MTQyNjYwMTddLCBbInQwMDkxIiwgMC45MDcwMzE3MjRdLCBbInQwMDkyIiwgMC45MjcyNDI1NTJdLCBbInQwMDkzIiwgMC44ODkwMzk0NzVdLCBbInQwMDk0IiwgMC44OTg5NjgzNTZdLCBbInQwMDk1IiwgMC44OTc3NTQzNTFdLCBbInQwMDk2IiwgMC45MzI4NjEzMTddLCBbInQwMDk3IiwgMC44NjgyNzk0MzldLCBbInQwMDk4IiwgMC44Njk3OTgyMjFdLCBbInQwMDk5IiwgMC45NDU3MDI4OF0sIFsidDAxMDAiLCAwLjg1NzAwOTgwMV0sIFsidDAxMDEiLCAwLjg5ODgzMTQyM10sIFsidDAxMDIiLCAwLjk1Nzg0MTc1OF0sIFsidDAxMDMiLCAwLjkwMDQ1MzEzNV0sIFsidDAxMDQiLCAwLjkxNzE1NzgxMV0sIFsidDAxMDUiLCAwLjk5MDUxMDE5XSwgWyJ0MDEwNiIsIDAuOTMxMzMxMTgxXSwgWyJ0MDEwNyIsIDAuODc2NTE2MTY2XSwgWyJ0MDEwOCIsIDAuODk2NTIyMzY2XSwgWyJ0MDEwOSIsIDAuODk5MjAxMTY4XSwgWyJ0MDExMCIsIDAuODYxNzM2NzFdLCBbInQwMTExIiwgMC44Njc5MTEyN10sIFsidDAxMTIiLCAwLjkyNzIyMzU5MV0sIFsidDAxMTMiLCAwLjg0NzkwMjA2Ml0sIFsidDAxMTQiLCAwLjgzMDA2NTEzOF0sIFsidDAxMTUiLCAwLjkwMjkxOTMwOV0sIFsidDAxMTYiLCAwLjg3ODA3MTAyMl0sIFsidDAxMTciLCAwLjg2OTY0MTQzXSwgWyJ0MDExOCIsIDAuOTA5NjI4NzI0XSwgWyJ0MDExOSIsIDAuOTU5NjkwODg2XSwgWyJ0MDEyMCIsIDAuODk5NDk5NDAzXV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwNzMiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA3MyIsIDAuODk2MzQ1NDQ2XSwgWyJ0MDA3NCIsIDAuOTg5MTYxMDgxXSwgWyJ0MDA3NSIsIDAuOTQ0NzUyNjA3XSwgWyJ0MDA3NiIsIDAuOTI4Mzc2NDU2XSwgWyJ0MDA3NyIsIDAuOTU2NzA1OTk2XSwgWyJ0MDA3OCIsIDAuODU3ODg0MDY2XSwgWyJ0MDA3OSIsIDAuODQ3OTM0NTg0XSwgWyJ0MDA4MCIsIDAuODk0MDEyMzddLCBbInQwMDgxIiwgMC44NjMzNjg5NjddLCBbInQwMDgyIiwgMC45MTAxNTEwNTNdLCBbInQwMDgzIiwgMC45MzgxNTI3NDVdLCBbInQwMDg0IiwgMC45MjQ5NzcyMzVdLCBbInQwMDg1IiwgMC45NDczNjYxMjRdLCBbInQwMDg2IiwgMC44Nzc1MzQxNV0sIFsidDAwODciLCAwLjg5MDQ3OTE4M10sIFsidDAwODgiLCAwLjg5NTkzODI2OV0sIFsidDAwODkiLCAwLjkwODkzMTkzOV0sIFsidDAwOTAiLCAwLjg0Njk1NDI1XSwgWyJ0MDA5MSIsIDAuOTE3NTg3NDkxXSwgWyJ0MDA5MiIsIDAuODY1OTM1OTM0XSwgWyJ0MDA5MyIsIDAuODc3MjI1NDddLCBbInQwMDk0IiwgMC45MDY0NzA5ODZdLCBbInQwMDk1IiwgMC44MTMxNjE3NzJdLCBbInQwMDk2IiwgMC44OTAwOTg2MTJdLCBbInQwMDk3IiwgMC44NzI0NjkzNDldLCBbInQwMDk4IiwgMC44NDA1NjEzNjldLCBbInQwMDk5IiwgMC44OTE5OTM4NjJdLCBbInQwMTAwIiwgMC44MjczMjAwMTRdLCBbInQwMTAxIiwgMC45MTQ0NTk2MjZdLCBbInQwMTAyIiwgMC45MjM2Mjg3NjRdLCBbInQwMTAzIiwgMC44NDM5NDUxODldLCBbInQwMTA0IiwgMC45MDU2MjE5MDldLCBbInQwMTA1IiwgMC45MDU5OTQ5ODZdLCBbInQwMTA2IiwgMC44MzA1NDk5MjJdLCBbInQwMTA3IiwgMC44OTI1MjQwODldLCBbInQwMTA4IiwgMC45NzA3NTMxNzhdLCBbInQwMTA5IiwgMC44NDk1OTAzMjldLCBbInQwMTEwIiwgMC45MjQzNTUzMTJdLCBbInQwMTExIiwgMC45NzUxMTM3NjRdLCBbInQwMTEyIiwgMC45MDQ1MzgxNDZdLCBbInQwMTEzIiwgMC44NjQ0Nzk3MzddLCBbInQwMTE0IiwgMC44OTYzMjgwMTVdLCBbInQwMTE1IiwgMC45NzIzMTU4NzJdLCBbInQwMTE2IiwgMC45NDg4NTAwNDldLCBbInQwMTE3IiwgMC44Njk2NDgyMTFdLCBbInQwMTE4IiwgMC44NTg4MDQyMDFdLCBbInQwMTE5IiwgMC45MDM4NDYzMTRdLCBbInQwMTIwIiwgMC44MDk4MTA3NjJdLCBbInQwMTIxIiwgMC45MDI0MTI3MjRdLCBbInQwMTIyIiwgMC45MDUxMTUwMjFdLCBbInQwMTIzIiwgMC44ODg5ODI3NzNdLCBbInQwMTI0IiwgMC44ODAyNjcyMjNdLCBbInQwMTI1IiwgMC44NjU1NjIxNjddLCBbInQwMTI2IiwgMC45MDQ2Mzk1MDVdLCBbInQwMTI3IiwgMC45MDU5MDkyNjVdLCBbInQwMTI4IiwgMC45MjI0NDkxN10sIFsidDAxMjkiLCAwLjkwMTgzNTQ0OV0sIFsidDAxMzAiLCAwLjg1NjU4NjQ3NF0sIFsidDAxMzEiLCAwLjg5NTc5MjU2NF0sIFsidDAxMzIiLCAwLjk4NDA5MTE3NF0sIFsidDAxMzMiLCAwLjkwMDAxOTg2MV0sIFsidDAxMzQiLCAwLjkyNTg5NzIxN10sIFsidDAxMzUiLCAwLjgyNTk4MTc5OF0sIFsidDAxMzYiLCAwLjg0OTAwNzk4Ml0sIFsidDAxMzciLCAwLjkyMzQ5NzQ2Nl0sIFsidDAxMzgiLCAwLjg2Mjk3MDE0NF0sIFsidDAxMzkiLCAwLjgyNjE1Mjc5NV0sIFsidDAxNDAiLCAwLjgxMDY4MzA5Ml0sIFsidDAxNDEiLCAwLjg2MDkxMTcxNF0sIFsidDAxNDIiLCAwLjk0MzM5NTY0XSwgWyJ0MDE0MyIsIDAuOTE2NDgxOTM4XSwgWyJ0MDE0NCIsIDAuOTM5MzA2Mzk3XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwOTciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA5NyIsIDAuODQ1MjczODI4XSwgWyJ0MDA5OCIsIDAuOTMxNzIxNTMxXSwgWyJ0MDA5OSIsIDAuOTE3Mzg5NjQ5XSwgWyJ0MDEwMCIsIDAuOTEwOTMyOTQzXSwgWyJ0MDEwMSIsIDAuOTEyMzA5ODA0XSwgWyJ0MDEwMiIsIDAuOTExMTg4MjQzXSwgWyJ0MDEwMyIsIDAuODg1NzA4NzYyXSwgWyJ0MDEwNCIsIDAuODc2OTk0Nzg3XSwgWyJ0MDEwNSIsIDAuOTAwMDYwNDZdLCBbInQwMTA2IiwgMC44MjI3MzcwNF0sIFsidDAxMDciLCAwLjg4NjM2NDgxMl0sIFsidDAxMDgiLCAwLjg2NTkyMzUyNl0sIFsidDAxMDkiLCAwLjg5NTM2MjUyMl0sIFsidDAxMTAiLCAwLjg1NDYxMDY4MV0sIFsidDAxMTEiLCAwLjg0Nzc3NTIxNl0sIFsidDAxMTIiLCAwLjg4MzI4OTMzNV0sIFsidDAxMTMiLCAwLjkwOTY4OTUwN10sIFsidDAxMTQiLCAwLjk4MDYwMjIwNF0sIFsidDAxMTUiLCAwLjkwNTY1OTMzMl0sIFsidDAxMTYiLCAwLjgyNDM1NzE5Ml0sIFsidDAxMTciLCAwLjg4ODk0OTIzMV0sIFsidDAxMTgiLCAwLjg1MzU1OTIzNl0sIFsidDAxMTkiLCAwLjk0MTIwMjc3M10sIFsidDAxMjAiLCAwLjgxNzU3OTI4N10sIFsidDAxMjEiLCAwLjg0MTkxMDUxMV0sIFsidDAxMjIiLCAwLjg3Njc0NTA4Ml0sIFsidDAxMjMiLCAwLjk4Mjg1MzE4OF0sIFsidDAxMjQiLCAwLjk1NzYzMzg2Ml0sIFsidDAxMjUiLCAwLjgzNzQ0NjI2M10sIFsidDAxMjYiLCAwLjkzODU5NDU1OV0sIFsidDAxMjciLCAwLjg0MDcxNDQ1OF0sIFsidDAxMjgiLCAwLjg5OTYwNjUwN10sIFsidDAxMjkiLCAwLjgxMzE5NzA1M10sIFsidDAxMzAiLCAwLjkyOTMzMTExXSwgWyJ0MDEzMSIsIDAuOTQ2NTgzNF0sIFsidDAxMzIiLCAwLjg3ODUwMzcwM10sIFsidDAxMzMiLCAwLjkxNzk3ODE2Ml0sIFsidDAxMzQiLCAwLjk2NTg1MjgxXSwgWyJ0MDEzNSIsIDAuODQyOTUzMTldLCBbInQwMTM2IiwgMC45NTgzNzkxNjRdLCBbInQwMTM3IiwgMC45MTQzMzU4NzldLCBbInQwMTM4IiwgMC44MjMxMDI2MzJdLCBbInQwMTM5IiwgMC44NzAwMTIxMTJdLCBbInQwMTQwIiwgMC44OTIwODYwNjZdLCBbInQwMTQxIiwgMC45MTMzNzc3MjFdLCBbInQwMTQyIiwgMC44ODMyNjcwMThdLCBbInQwMTQzIiwgMC44NDkzNTI2NDNdLCBbInQwMTQ0IiwgMC45NjMxNDMzMjVdLCBbInQwMTQ1IiwgMC45NzczMDA1MDZdLCBbInQwMTQ2IiwgMC44OTI3NTkxOTddLCBbInQwMTQ3IiwgMC44NjgyMzY0MV0sIFsidDAxNDgiLCAwLjg2MjExMTY0OF0sIFsidDAxNDkiLCAwLjgxODM4NTA3OV0sIFsidDAxNTAiLCAwLjg0NzI3OTIzMl0sIFsidDAxNTEiLCAwLjkyMjMxMTA4N10sIFsidDAxNTIiLCAwLjg2Njk1NjM0M10sIFsidDAxNTMiLCAwLjg2ODYwNDY2Nl0sIFsidDAxNTQiLCAwLjkzNjk4MTU4N10sIFsidDAxNTUiLCAwLjg3NDAzMzEwM10sIFsidDAxNTYiLCAwLjgzMDExODQyMV0sIFsidDAxNTciLCAwLjkxNTUzMDA1XSwgWyJ0MDE1OCIsIDAuODc1NDg4NTE5XSwgWyJ0MDE1OSIsIDAuODkxMjIyMDg2XSwgWyJ0MDE2MCIsIDAuOTA4MjU4MDg2XSwgWyJ0MDE2MSIsIDAuOTczMjMzNzkyXSwgWyJ0MDE2MiIsIDAuODk4MTg1NDUzXSwgWyJ0MDE2MyIsIDAuODkyNzM2MzI5XSwgWyJ0MDE2NCIsIDAuOTE2ODU1NTQ1XSwgWyJ0MDE2NSIsIDAuODgwOTQ3NTQyXSwgWyJ0MDE2NiIsIDAuODUzNTcyNDZdLCBbInQwMTY3IiwgMC44NjAwMjQyMjRdLCBbInQwMTY4IiwgMC44OTYwNjI1MjZdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDEyMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMTIxIiwgMC44NjMxNjYwNzRdLCBbInQwMTIyIiwgMC45MzUwOTgxMzddLCBbInQwMTIzIiwgMC45NTAzNjk4MDhdLCBbInQwMTI0IiwgMC45MjI3OTQwMzRdLCBbInQwMTI1IiwgMC44ODk1MTk0NDFdLCBbInQwMTI2IiwgMC45NzM3MTQ3NTZdLCBbInQwMTI3IiwgMC44NDk2NzUwNzRdLCBbInQwMTI4IiwgMC45NTI1MDQ1NTRdLCBbInQwMTI5IiwgMC45MDA1Nzg4MjJdLCBbInQwMTMwIiwgMC44MTU0MDc2NTddLCBbInQwMTMxIiwgMC45MDQ4MTYyNjddLCBbInQwMTMyIiwgMC45MTk3MzcxNzldLCBbInQwMTMzIiwgMC44MjkyMTM2ODddLCBbInQwMTM0IiwgMC44NTExODEwNzZdLCBbInQwMTM1IiwgMC44NzMwMDI3NjhdLCBbInQwMTM2IiwgMC44OTM5NjI1ODVdLCBbInQwMTM3IiwgMC45MTM2MDIxMzhdLCBbInQwMTM4IiwgMC44ODU0MTc3MTRdLCBbInQwMTM5IiwgMC44ODI5MjE1NDRdLCBbInQwMTQwIiwgMC45MjI1NDI3NDNdLCBbInQwMTQxIiwgMC45MjYzNTgyMDNdLCBbInQwMTQyIiwgMC45MjU1Mzg4MDJdLCBbInQwMTQzIiwgMC45MDAxNjIyODRdLCBbInQwMTQ0IiwgMC44NjQ0ODc0MjVdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDE0NSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMTQ1IiwgMC44OTI5MjI4MzNdLCBbInQwMTQ2IiwgMC45MTQyNTcyMDhdLCBbInQwMTQ3IiwgMC44NjQ4MjY2ODVdLCBbInQwMTQ4IiwgMC44NzE5NDQ3NzVdLCBbInQwMTQ5IiwgMC45MjM0OTA1NzddLCBbInQwMTUwIiwgMC45MzYwOTk0MjRdLCBbInQwMTUxIiwgMC44OTM4NjIwMThdLCBbInQwMTUyIiwgMC45MTU5Mjg2MDddLCBbInQwMTUzIiwgMC45NzY5MzExMjldLCBbInQwMTU0IiwgMC45MDEyODQwNjVdLCBbInQwMTU1IiwgMC45NDcwNDY4NTldLCBbInQwMTU2IiwgMC44NTA2NTcyMjJdLCBbInQwMTU3IiwgMC45MTQ3MDA4NjZdLCBbInQwMTU4IiwgMC45NDM3MzMyNDFdLCBbInQwMTU5IiwgMC45MDY2MzQ2MjFdLCBbInQwMTYwIiwgMC44NjczODA0MzddLCBbInQwMTYxIiwgMC44NjYwNTQ2MV0sIFsidDAxNjIiLCAwLjgwNjcyNzE2NF0sIFsidDAxNjMiLCAwLjk2NzI3ODM1OV0sIFsidDAxNjQiLCAwLjk2OTIxMTEwOF0sIFsidDAxNjUiLCAwLjg0Njc0NzE3OV0sIFsidDAxNjYiLCAwLjg4NjI1OTMwNF0sIFsidDAxNjciLCAwLjk4ODc0MjI5OV0sIFsidDAxNjgiLCAwLjkwMTg5ODUwNF1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dXSwgImluZGV4X25hbWUiOiAiYW5hbHlzaXNfdGltZSJ9XSwgWyJsb3dlciIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjIxNTExMDU5M10sIFsidDAwMDIiLCAwLjIyMjkzOTAzMV0sIFsidDAwMDMiLCAwLjI0MTE1NzQ5NF0sIFsidDAwMDQiLCAwLjIyNzYyNzgyMV0sIFsidDAwMDUiLCAwLjE3OTk3MzUwMV0sIFsidDAwMDYiLCAwLjI3NjYyNjcyNl0sIFsidDAwMDciLCAwLjE4Mzc2MDM4Ml0sIFsidDAwMDgiLCAwLjE3MzIxOTEyXSwgWyJ0MDAwOSIsIDAuMjYwMjAyOTY2XSwgWyJ0MDAxMCIsIDAuMjI4OTkyMzU4XSwgWyJ0MDAxMSIsIDAuMTMzNDQ1MTMxXSwgWyJ0MDAxMiIsIDAuMTQ1MTc0MDgxXSwgWyJ0MDAxMyIsIDAuMTY3OTIxNF0sIFsidDAwMTQiLCAwLjEzMjQxNTc1Nl0sIFsidDAwMTUiLCAwLjE5NTY1MTA4M10sIFsidDAwMTYiLCAwLjI4ODk4ODkzMV0sIFsidDAwMTciLCAwLjIxNTA1OTQ1N10sIFsidDAwMTgiLCAwLjE1MDQ5OTcxNV0sIFsidDAwMTkiLCAwLjE4NTM1OTA2OF0sIFsidDAwMjAiLCAwLjE3OTQwNTQ2NF0sIFsidDAwMjEiLCAwLjI0NDk4NzEzMV0sIFsidDAwMjIiLCAwLjE1Njg1NDQ5XSwgWyJ0MDAyMyIsIDAuMjU2ODg4MzExXSwgWyJ0MDAyNCIsIDAuMTMzNzQxNjYzXV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwMjUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAyNSIsIDAuMjAwMzAxMTUyXSwgWyJ0MDAyNiIsIDAuMTcwOTM4MTUzXSwgWyJ0MDAyNyIsIDAuMjU2NTI1Mzc5XSwgWyJ0MDAyOCIsIDAuMTQxMjQxMzY3XSwgWyJ0MDAyOSIsIDAuMTkzNjY4OTg1XSwgWyJ0MDAzMCIsIDAuMTQzMzE0MDc2XSwgWyJ0MDAzMSIsIDAuMjQzMDk1NTg0XSwgWyJ0MDAzMiIsIDAuMTU5MzI0Mzg0XSwgWyJ0MDAzMyIsIDAuMjA5NTkyNDU0XSwgWyJ0MDAzNCIsIDAuMTcyNDAwODQxXSwgWyJ0MDAzNSIsIDAuMjA0NTczMTQ0XSwgWyJ0MDAzNiIsIDAuMTc4MDc1NjgyXSwgWyJ0MDAzNyIsIDAuMTc0NzcyMTgyXSwgWyJ0MDAzOCIsIDAuMjA4ODMxMDQzXSwgWyJ0MDAzOSIsIDAuMjU1NzU5NzAxXSwgWyJ0MDA0MCIsIDAuMTk3ODE4MDkxXSwgWyJ0MDA0MSIsIDAuMTk5NzY2NjA0XSwgWyJ0MDA0MiIsIDAuMTkwMTU5MzE1XSwgWyJ0MDA0MyIsIDAuMjIwOTg5MTI3XSwgWyJ0MDA0NCIsIDAuMTc1OTY1NDQ5XSwgWyJ0MDA0NSIsIDAuMjQzMjM1Njg4XSwgWyJ0MDA0NiIsIDAuMTkyMDk1MzYzXSwgWyJ0MDA0NyIsIDAuMTYzOTE2MDkzXSwgWyJ0MDA0OCIsIDAuMjQ4NDYwNjA0XSwgWyJ0MDA0OSIsIDAuMTUyMTY1Nzc0XSwgWyJ0MDA1MCIsIDAuMjQxNzM2MDAzXSwgWyJ0MDA1MSIsIDAuMjAzNjA2XSwgWyJ0MDA1MiIsIDAuMjAxMjAzODQ1XSwgWyJ0MDA1MyIsIDAuMTY4NjE0MDc0XSwgWyJ0MDA1NCIsIDAuMTkwNDA4MjQ3XSwgWyJ0MDA1NSIsIDAuMjI5MTExODM1XSwgWyJ0MDA1NiIsIDAuMTc4NTUwMTE3XSwgWyJ0MDA1NyIsIDAuMjIyODM4Mzc3XSwgWyJ0MDA1OCIsIDAuMTc4MTMyMjI2XSwgWyJ0MDA1OSIsIDAuMjI2ODAwMzNdLCBbInQwMDYwIiwgMC4yNzczOTA3NF0sIFsidDAwNjEiLCAwLjIwODY1NTA1Nl0sIFsidDAwNjIiLCAwLjIyNjAyMzUyNl0sIFsidDAwNjMiLCAwLjI4Mjg0MzkxXSwgWyJ0MDA2NCIsIDAuMjMxNzY1MTE4XSwgWyJ0MDA2NSIsIDAuMjQwNzQxNjA2XSwgWyJ0MDA2NiIsIDAuMjY0MzA1NzI4XSwgWyJ0MDA2NyIsIDAuMTU0NDczNDU0XSwgWyJ0MDA2OCIsIDAuMTc5MDQ4ODddLCBbInQwMDY5IiwgMC4yMjIzNDc3MDNdLCBbInQwMDcwIiwgMC4yMTU5NTE4NjFdLCBbInQwMDcxIiwgMC4xNDc3MzAyMV0sIFsidDAwNzIiLCAwLjE2NTc1MTM4OV0sIFsidDAwNzMiLCAwLjEzNTMyOTk3Ml0sIFsidDAwNzQiLCAwLjE4NjMyOTc1M10sIFsidDAwNzUiLCAwLjE3NjE3ODk3Nl0sIFsidDAwNzYiLCAwLjE1OTQzOTQ2NV0sIFsidDAwNzciLCAwLjE3MzgwOTY2NF0sIFsidDAwNzgiLCAwLjI4ODY1ODU5XSwgWyJ0MDA3OSIsIDAuMTYwNjQ5OTQ0XSwgWyJ0MDA4MCIsIDAuMjA5MTE5NDY2XSwgWyJ0MDA4MSIsIDAuMjE1MTEwODAyXSwgWyJ0MDA4MiIsIDAuMjQzNDQ0NjQ2XSwgWyJ0MDA4MyIsIDAuMTk5MjE5NjMxXSwgWyJ0MDA4NCIsIDAuMTkxOTY5NjI3XSwgWyJ0MDA4NSIsIDAuMTIyMjc2NDIxXSwgWyJ0MDA4NiIsIDAuMTUyNDQ1MDU0XSwgWyJ0MDA4NyIsIDAuMjE2NTc3MDU0XSwgWyJ0MDA4OCIsIDAuMTk4NTc3NzExXSwgWyJ0MDA4OSIsIDAuMjA0ODg5ODk3XSwgWyJ0MDA5MCIsIDAuMjQwODAyMTA3XSwgWyJ0MDA5MSIsIDAuMjQyOTQyNjA4XSwgWyJ0MDA5MiIsIDAuMjM1OTUyOTQ2XSwgWyJ0MDA5MyIsIDAuMjYzMzI0MzE4XSwgWyJ0MDA5NCIsIDAuMTgzNTc4NTc0XSwgWyJ0MDA5NSIsIDAuMTg4OTkyMzkxXSwgWyJ0MDA5NiIsIDAuMTgyMTc5OTk3XV0sICJpbmRleF9uYW1lIjogInRpbWUifV0sIFsidDAwNDkiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDA0OSIsIDAuMTk2OTU3OTQxXSwgWyJ0MDA1MCIsIDAuMjI5MzEzOTc2XSwgWyJ0MDA1MSIsIDAuMTc3NDI4NzE2XSwgWyJ0MDA1MiIsIDAuMjUzNDQ1MDk3XSwgWyJ0MDA1MyIsIDAuMjE2ODczNTQxXSwgWyJ0MDA1NCIsIDAuMjQyNjkwMjZdLCBbInQwMDU1IiwgMC4xNTcxODE0MzRdLCBbInQwMDU2IiwgMC4xOTgwMTg1NjhdLCBbInQwMDU3IiwgMC4yNjI3NjYxNTFdLCBbInQwMDU4IiwgMC4yMjQ5NzU4NTNdLCBbInQwMDU5IiwgMC4xODk0MDk5N10sIFsidDAwNjAiLCAwLjE2MzY4ODE4MV0sIFsidDAwNjEiLCAwLjE0MzM5MDIyOV0sIFsidDAwNjIiLCAwLjIwOTkzNjI0XSwgWyJ0MDA2MyIsIDAuMTc0OTcxOTk3XSwgWyJ0MDA2NCIsIDAuMjcyMzYwNTQ2XSwgWyJ0MDA2NSIsIDAuMjI0MzI5OTEyXSwgWyJ0MDA2NiIsIDAuMjE0NTU0MDVdLCBbInQwMDY3IiwgMC4yMjE0NTI2MzldLCBbInQwMDY4IiwgMC4xOTE0NjYxNzldLCBbInQwMDY5IiwgMC4yMDY4MzQ5MjddLCBbInQwMDcwIiwgMC4yMzk4MjIxNjZdLCBbInQwMDcxIiwgMC4xODc1NTI0ODFdLCBbInQwMDcyIiwgMC4yODkzNTc4XSwgWyJ0MDA3MyIsIDAuMTgyMDYxMzI3XSwgWyJ0MDA3NCIsIDAuMjMwNzQxNTc3XSwgWyJ0MDA3NSIsIDAuMTY3NzQzNjk1XSwgWyJ0MDA3NiIsIDAuMTYyMDI1MDAxXSwgWyJ0MDA3NyIsIDAuMTY0NzEzNDMyXSwgWyJ0MDA3OCIsIDAuMjMxOTkzOTddLCBbInQwMDc5IiwgMC4yMTQ1NTk4OTNdLCBbInQwMDgwIiwgMC4xNzY1Njk0NDZdLCBbInQwMDgxIiwgMC4xNjk4ODA2NDhdLCBbInQwMDgyIiwgMC4yODAyNzg4NzNdLCBbInQwMDgzIiwgMC4xNzk5NjA3MzFdLCBbInQwMDg0IiwgMC4yMDQ2MjE4NzhdLCBbInQwMDg1IiwgMC4xNTYxMjkxMV0sIFsidDAwODYiLCAwLjIwOTMwMzE3NV0sIFsidDAwODciLCAwLjE2NTMxODAzM10sIFsidDAwODgiLCAwLjE0NzIyMTM1OF0sIFsidDAwODkiLCAwLjIwMjg1ODk3NV0sIFsidDAwOTAiLCAwLjE5MzI1MTA1NV0sIFsidDAwOTEiLCAwLjIxNDUyMjkxOV0sIFsidDAwOTIiLCAwLjIzODM3MTAyN10sIFsidDAwOTMiLCAwLjIzMjEwMjg1XSwgWyJ0MDA5NCIsIDAuMjE5ODUxOTEzXSwgWyJ0MDA5NSIsIDAuMTY2MjQyNDc2XSwgWyJ0MDA5NiIsIDAuMjI2MTY3NjA1XSwgWyJ0MDA5NyIsIDAuMTgwNzE4MTM5XSwgWyJ0MDA5OCIsIDAuMjIzODA0ODk0XSwgWyJ0MDA5OSIsIDAuMjI5NjY3NTAxXSwgWyJ0MDEwMCIsIDAuMjE2NDc1OTk0XSwgWyJ0MDEwMSIsIDAuMjc5Mzg1NjA4XSwgWyJ0MDEwMiIsIDAuMjc4ODc0MjY3XSwgWyJ0MDEwMyIsIDAuMjg3NjE1NTQ4XSwgWyJ0MDEwNCIsIDAuMjE5MDY0MThdLCBbInQwMTA1IiwgMC4xOTk0MjI5MTddLCBbInQwMTA2IiwgMC4yMzk2NzI5MDVdLCBbInQwMTA3IiwgMC4xNzg0NDc4NTNdLCBbInQwMTA4IiwgMC4xNzU4Mzg0NTddLCBbInQwMTA5IiwgMC4xOTU0MzcwMDZdLCBbInQwMTEwIiwgMC4xNzE3NTI0NzVdLCBbInQwMTExIiwgMC4yMjAxNDMwMzFdLCBbInQwMTEyIiwgMC4xNjczMzQ3NTldLCBbInQwMTEzIiwgMC4xODcyOTA0OTVdLCBbInQwMTE0IiwgMC4yMDA1NjgyNzNdLCBbInQwMTE1IiwgMC4xOTA3ODYwMjVdLCBbInQwMTE2IiwgMC4yMzY0MjM0NjVdLCBbInQwMTE3IiwgMC4yMzM0NDQ4OTRdLCBbInQwMTE4IiwgMC4yMzk3MjEwMThdLCBbInQwMTE5IiwgMC4xODMwODE4NjhdLCBbInQwMTIwIiwgMC4yMzM5MjE0MDFdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDA3MyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDczIiwgMC4xODE2ODc2NV0sIFsidDAwNzQiLCAwLjE1MDEzOTA5Ml0sIFsidDAwNzUiLCAwLjEzOTg5OTMwNV0sIFsidDAwNzYiLCAwLjI1MTk1MzgzOF0sIFsidDAwNzciLCAwLjI1NjM1NjUzXSwgWyJ0MDA3OCIsIDAuMTU4MzM2MDYyXSwgWyJ0MDA3OSIsIDAuMTk1NDI2MjI4XSwgWyJ0MDA4MCIsIDAuMjI2MjA5MDgyXSwgWyJ0MDA4MSIsIDAuMTkyODkwNDIxXSwgWyJ0MDA4MiIsIDAuMTc0MDMxMjddLCBbInQwMDgzIiwgMC4yMTYzMjcxOTJdLCBbInQwMDg0IiwgMC4xNDcyMjA4MzhdLCBbInQwMDg1IiwgMC4xMjA2NjM2MzVdLCBbInQwMDg2IiwgMC4xOTgxNTEzODJdLCBbInQwMDg3IiwgMC4xNjYxNDM0OThdLCBbInQwMDg4IiwgMC4yMTM1MzMwNV0sIFsidDAwODkiLCAwLjEwMjkyOTgwN10sIFsidDAwOTAiLCAwLjI2MzQ3MjczNV0sIFsidDAwOTEiLCAwLjE5ODYyNzY0Nl0sIFsidDAwOTIiLCAwLjE3NzE0NDAwMV0sIFsidDAwOTMiLCAwLjE1ODkwNTk1XSwgWyJ0MDA5NCIsIDAuMTk5MjYwNjk1XSwgWyJ0MDA5NSIsIDAuMTg4Mjc3MTczXSwgWyJ0MDA5NiIsIDAuMTUyMDg4ODIzXSwgWyJ0MDA5NyIsIDAuMjczNjM4OTUzXSwgWyJ0MDA5OCIsIDAuMjc4NzA1NDUxXSwgWyJ0MDA5OSIsIDAuMjQ3MTMyNTUxXSwgWyJ0MDEwMCIsIDAuMTg2OTQ1ODA4XSwgWyJ0MDEwMSIsIDAuMTY5NzczMzMyXSwgWyJ0MDEwMiIsIDAuMTE4NjIwMDkyXSwgWyJ0MDEwMyIsIDAuMjE5NDY2MzY1XSwgWyJ0MDEwNCIsIDAuMTgxNjg1NTg3XSwgWyJ0MDEwNSIsIDAuMjQ3MzY1MjVdLCBbInQwMTA2IiwgMC4yMTk0MjA3XSwgWyJ0MDEwNyIsIDAuMjA4OTQ2MDcxXSwgWyJ0MDEwOCIsIDAuMjEyMDIwNDU1XSwgWyJ0MDEwOSIsIDAuMjkyMTAwMzA5XSwgWyJ0MDExMCIsIDAuMTMxMTY1MjQ0XSwgWyJ0MDExMSIsIDAuMjMzMTAyMjE2XSwgWyJ0MDExMiIsIDAuMjMzNjU0NTAzXSwgWyJ0MDExMyIsIDAuMjA2NTQ0NDc3XSwgWyJ0MDExNCIsIDAuMTcyNTMxOTM4XSwgWyJ0MDExNSIsIDAuMTYxNTI5MjFdLCBbInQwMTE2IiwgMC4xNjA3MDY2NjddLCBbInQwMTE3IiwgMC4yMTE0Nzc1NjVdLCBbInQwMTE4IiwgMC4yMzM1NTM1ODldLCBbInQwMTE5IiwgMC4xMjk3Nzg0ODhdLCBbInQwMTIwIiwgMC4xNTA3MjAyNzddLCBbInQwMTIxIiwgMC4yMzg4MDAxOTddLCBbInQwMTIyIiwgMC4xOTI0NzYwNjddLCBbInQwMTIzIiwgMC4xNjY1OTI0OTNdLCBbInQwMTI0IiwgMC4xNzk5Njc3MjRdLCBbInQwMTI1IiwgMC4xOTk5MTUyMV0sIFsidDAxMjYiLCAwLjI3MjQ1NzM2NV0sIFsidDAxMjciLCAwLjIxODM2NDkxNl0sIFsidDAxMjgiLCAwLjIyNTc0Njc3NF0sIFsidDAxMjkiLCAwLjE4ODQzOTk5Ml0sIFsidDAxMzAiLCAwLjEyMDEyNTA3OV0sIFsidDAxMzEiLCAwLjE4MTI3NDA2N10sIFsidDAxMzIiLCAwLjI0ODIzNTEzMV0sIFsidDAxMzMiLCAwLjE3MzE4ODE0XSwgWyJ0MDEzNCIsIDAuMTM2ODgzMjY3XSwgWyJ0MDEzNSIsIDAuMjc0NTg5OTI5XSwgWyJ0MDEzNiIsIDAuMTcwNzAwNTY5XSwgWyJ0MDEzNyIsIDAuMTUyMTEzNjkzXSwgWyJ0MDEzOCIsIDAuMjE4NjA0MDEzXSwgWyJ0MDEzOSIsIDAuMTU4MzIzODg0XSwgWyJ0MDE0MCIsIDAuMTMwNTAwMDAzXSwgWyJ0MDE0MSIsIDAuMjUzMTA4MjUyXSwgWyJ0MDE0MiIsIDAuMjE2NjY1OTg2XSwgWyJ0MDE0MyIsIDAuMTU3MjkxOTE2XSwgWyJ0MDE0NCIsIDAuMjY3NTEzMzRdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDA5NyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDk3IiwgMC4yNjE0MTQ3NTNdLCBbInQwMDk4IiwgMC4xODIzNDUzNDZdLCBbInQwMDk5IiwgMC4xNjY5MjgzMjldLCBbInQwMTAwIiwgMC4xOTI3MjM5NjRdLCBbInQwMTAxIiwgMC4xOTE3NjMyOTldLCBbInQwMTAyIiwgMC4yMzc4NDExODldLCBbInQwMTAzIiwgMC4yMDgxNjk5NTRdLCBbInQwMTA0IiwgMC4yNzkxNDcyNjFdLCBbInQwMTA1IiwgMC4yMzI0MTU2MzFdLCBbInQwMTA2IiwgMC4yMzM5NzkyOTZdLCBbInQwMTA3IiwgMC4yNzYwMTkxMjldLCBbInQwMTA4IiwgMC4xNjAxMDI3NjNdLCBbInQwMTA5IiwgMC4yNjgyNjk3NjVdLCBbInQwMTEwIiwgMC4yMjc3MzU4OTJdLCBbInQwMTExIiwgMC4xNzczMDE1MDddLCBbInQwMTEyIiwgMC4xOTUxOTA4NjhdLCBbInQwMTEzIiwgMC4yODMyNzg1Ml0sIFsidDAxMTQiLCAwLjEwNTY0OTMyNF0sIFsidDAxMTUiLCAwLjE5NjgzOTM4N10sIFsidDAxMTYiLCAwLjE1NDIyODQ2N10sIFsidDAxMTciLCAwLjE4ODY4MTkzNF0sIFsidDAxMTgiLCAwLjIwMTkzOTI4Nl0sIFsidDAxMTkiLCAwLjI3OTM1NzY2N10sIFsidDAxMjAiLCAwLjE5Mzg3NzAyOF0sIFsidDAxMjEiLCAwLjIzODMyNjMyMl0sIFsidDAxMjIiLCAwLjE1MDcwNDkyOV0sIFsidDAxMjMiLCAwLjEzMDc3OTA2Ml0sIFsidDAxMjQiLCAwLjI1NDA1NTIzMV0sIFsidDAxMjUiLCAwLjE5NjcxOTk4N10sIFsidDAxMjYiLCAwLjExMTkzODIxM10sIFsidDAxMjciLCAwLjEwODA0NzE4OV0sIFsidDAxMjgiLCAwLjE0MDE2ODQ5MV0sIFsidDAxMjkiLCAwLjI1MjY1MDY0Nl0sIFsidDAxMzAiLCAwLjIyNjczNjczN10sIFsidDAxMzEiLCAwLjE3NzI5ODM3XSwgWyJ0MDEzMiIsIDAuMTc3NzUyMDM0XSwgWyJ0MDEzMyIsIDAuMjEyMjI5MzMzXSwgWyJ0MDEzNCIsIDAuMjQ4OTU4NTY1XSwgWyJ0MDEzNSIsIDAuMTMzMzQzNzY1XSwgWyJ0MDEzNiIsIDAuMjAxNjk1NzMzXSwgWyJ0MDEzNyIsIDAuMTc4NzU2NDE4XSwgWyJ0MDEzOCIsIDAuMjc3NTg1NzE4XSwgWyJ0MDEzOSIsIDAuMTQ3MzI4MzU5XSwgWyJ0MDE0MCIsIDAuMTIxMTI4MzY4XSwgWyJ0MDE0MSIsIDAuMjI2MjU5MTk5XSwgWyJ0MDE0MiIsIDAuMjU1OTg4NDc0XSwgWyJ0MDE0MyIsIDAuMjA2NTM1MzY4XSwgWyJ0MDE0NCIsIDAuMTcwOTY1OTk1XSwgWyJ0MDE0NSIsIDAuMTc1MTQyNTU4XSwgWyJ0MDE0NiIsIDAuMjE1Mjg3Mjg3XSwgWyJ0MDE0NyIsIDAuMjI1NDk1NzE0XSwgWyJ0MDE0OCIsIDAuMTE3OTE5NzE3XSwgWyJ0MDE0OSIsIDAuMTYzODk4ODc0XSwgWyJ0MDE1MCIsIDAuMjA3MzUxNzU4XSwgWyJ0MDE1MSIsIDAuMjAyNDc3ODgxXSwgWyJ0MDE1MiIsIDAuMjQzNDQ5OTI4XSwgWyJ0MDE1MyIsIDAuMjMyNTU4ODg2XSwgWyJ0MDE1NCIsIDAuMTQwMTA3MjldLCBbInQwMTU1IiwgMC4xODY0MTg5N10sIFsidDAxNTYiLCAwLjIyNTExNTA1NF0sIFsidDAxNTciLCAwLjIxMDg0MjQzM10sIFsidDAxNTgiLCAwLjIyMjU0OTM0M10sIFsidDAxNTkiLCAwLjIzMDQzOTQ0Ml0sIFsidDAxNjAiLCAwLjIzNDM2OTE3XSwgWyJ0MDE2MSIsIDAuMTcwMzk0Njg5XSwgWyJ0MDE2MiIsIDAuMjAxNjU3OTVdLCBbInQwMTYzIiwgMC4yMDY1MTUwNzldLCBbInQwMTY0IiwgMC4xODUwMjIzMDRdLCBbInQwMTY1IiwgMC4yMjExNjg2ODZdLCBbInQwMTY2IiwgMC4xNzk4NzQwNzFdLCBbInQwMTY3IiwgMC4xODM0NzEyNDJdLCBbInQwMTY4IiwgMC4xODIyMDA1MjNdXSwgImluZGV4X25hbWUiOiAidGltZSJ9XSwgWyJ0MDEyMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMTIxIiwgMC4yNDkxMDM5NDRdLCBbInQwMTIyIiwgMC4xNjI1MDEwOTNdLCBbInQwMTIzIiwgMC4yMTIzMTIyODhdLCBbInQwMTI0IiwgMC4xNTg5NjI1XSwgWyJ0MDEyNSIsIDAuMTkxNTk4MDM2XSwgWyJ0MDEyNiIsIDAuMTg3MzA3MTg3XSwgWyJ0MDEyNyIsIDAuMTcxNzMyNjgzXSwgWyJ0MDEyOCIsIDAuMTMwMTk1MTgyXSwgWyJ0MDEyOSIsIDAuMTgzODgwOTU4XSwgWyJ0MDEzMCIsIDAuMTk2Njg0NDY4XSwgWyJ0MDEzMSIsIDAuMTgzMjAxMzg2XSwgWyJ0MDEzMiIsIDAuMTc5NzY3Njg1XSwgWyJ0MDEzMyIsIDAuMjMyMjA4MDc3XSwgWyJ0MDEzNCIsIDAuMjE5MjE3NzM0XSwgWyJ0MDEzNSIsIDAuMTEzODE1MTM0XSwgWyJ0MDEzNiIsIDAuMTE5MDQ5OTg5XSwgWyJ0MDEzNyIsIDAuMTQxNzYzNDQ1XSwgWyJ0MDEzOCIsIDAuMjc5ODY1NjYzXSwgWyJ0MDEzOSIsIDAuMTY1MjY5Nl0sIFsidDAxNDAiLCAwLjI0NzMzOTc5N10sIFsidDAxNDEiLCAwLjE1OTA4MzcxNV0sIFsidDAxNDMiLCAwLjIxNDU5NDgwNV0sIFsidDAxNDQiLCAwLjE2NzEwMTMwOV1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTQyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxNDIiLCAwLjE5NDczMjE4OF1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dLCBbInQwMTQ1IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAxNDUiLCAwLjE0MTYyMDEwM10sIFsidDAxNDYiLCAwLjI2NDA4NTIzXSwgWyJ0MDE0NyIsIDAuMTc5NDA3OTldLCBbInQwMTQ4IiwgMC4xNzM5ODEwMTZdLCBbInQwMTQ5IiwgMC4yNDg5OTMzNjddLCBbInQwMTUwIiwgMC4xOTU5NDIzOTFdLCBbInQwMTUxIiwgMC4xNTQ1MzAyMzJdLCBbInQwMTUyIiwgMC4yMTMxOTUxNjldLCBbInQwMTUzIiwgMC4yNjEwNDMxODRdLCBbInQwMTU0IiwgMC4xODM2MTg3OTJdLCBbInQwMTU1IiwgMC4xNjkyOTQzNTNdLCBbInQwMTU2IiwgMC4yMjE2MzE3MDVdLCBbInQwMTU3IiwgMC4xMTA1MTMzNjVdLCBbInQwMTU4IiwgMC4xODcyMjEzOThdLCBbInQwMTU5IiwgMC4yMDU0OTc1MjNdLCBbInQwMTYwIiwgMC4yMTY0ODgzNDVdLCBbInQwMTYxIiwgMC4xMzA0MjkxNjJdLCBbInQwMTYyIiwgMC4yMjIxMzk1NF0sIFsidDAxNjMiLCAwLjE4MzkwNDE1MV0sIFsidDAxNjQiLCAwLjExMzEwNjEzNl0sIFsidDAxNjUiLCAwLjIyMjQzOTEzMV0sIFsidDAxNjYiLCAwLjE3OTI3Nzg5Ml0sIFsidDAxNjciLCAwLjI0MTU2ODExOV0sIFsidDAxNjgiLCAwLjIwNzI2Mjc0Nl1dLCAiaW5kZXhfbmFtZSI6ICJ0aW1lIn1dXSwgImluZGV4X25hbWUiOiAiYW5hbHlzaXNfdGltZSJ9XV0sICJpbmRleF9uYW1lIjogImJyYW5jaCJ9", + "map" + ], + "1week_rolling" + ], + [ + "profile", + "wind", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmVhbGl6ZWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC41XSwgWyJ0MDAwMiIsIDAuNDU5ODQ4NTA4XSwgWyJ0MDAwMyIsIDAuNDE0NzM1NTIxXSwgWyJ0MDAwNCIsIDAuMzg4MDgyOTIyXSwgWyJ0MDAwNSIsIDAuMzUyNTMyMTU2XSwgWyJ0MDAwNiIsIDAuMzk4MjkxNDA1XSwgWyJ0MDAwNyIsIDAuNDA3NTg4NTAzXSwgWyJ0MDAwOCIsIDAuNDY5NDU5MjY0XSwgWyJ0MDAwOSIsIDAuNDUyMjUwODEzXSwgWyJ0MDAxMCIsIDAuMzkwMzU1MzQxXSwgWyJ0MDAxMSIsIDAuMzk4MzY3Nzc4XSwgWyJ0MDAxMiIsIDAuMzkxNTU2Nzk3XSwgWyJ0MDAxMyIsIDAuNDAwNDUwODNdLCBbInQwMDE0IiwgMC4zOTczOTI1OTddLCBbInQwMDE1IiwgMC4zNDM0MzMwNjVdLCBbInQwMDE2IiwgMC4zNjc5NTg1OTldLCBbInQwMDE3IiwgMC4zODg1MTMwMDddLCBbInQwMDE4IiwgMC4zOTgzMDcyMzFdLCBbInQwMDE5IiwgMC40MzEwOTM1MTddLCBbInQwMDIwIiwgMC40NjE1NzUwMzZdLCBbInQwMDIxIiwgMC40NjA0NDYyNzddLCBbInQwMDIyIiwgMC40NDUyNzQ1ODFdLCBbInQwMDIzIiwgMC41Mzc4Mjg3MjRdLCBbInQwMDI0IiwgMC41MDA4ODMwODVdLCBbInQwMDI1IiwgMC41NzAyMjExNDZdLCBbInQwMDI2IiwgMC41MTg3ODcyMTJdLCBbInQwMDI3IiwgMC40NTQxOTc3NDZdLCBbInQwMDI4IiwgMC4zNjI3MTk3NV0sIFsidDAwMjkiLCAwLjQwMTkzNTE5MV0sIFsidDAwMzAiLCAwLjM4MzM0MTYyMl0sIFsidDAwMzEiLCAwLjM5MjQ1MjgwNF0sIFsidDAwMzIiLCAwLjM3MDY5NDc2NF0sIFsidDAwMzMiLCAwLjM4MTc3OTM2XSwgWyJ0MDAzNCIsIDAuNDYzMjAyMTc5XSwgWyJ0MDAzNSIsIDAuNTI2ODE3OTI1XSwgWyJ0MDAzNiIsIDAuNTQ4ODA2MjU3XSwgWyJ0MDAzNyIsIDAuNTY0NzUxOTYzXSwgWyJ0MDAzOCIsIDAuNTY1OTgwMjI3XSwgWyJ0MDAzOSIsIDAuNTM3NDE3NDE1XSwgWyJ0MDA0MCIsIDAuNTg1MzI2MTI2XSwgWyJ0MDA0MSIsIDAuNTU1MzIwMTU1XSwgWyJ0MDA0MiIsIDAuNTEwMTkxOTY3XSwgWyJ0MDA0MyIsIDAuNDQwMjQwMjQ1XSwgWyJ0MDA0NCIsIDAuNDk5Mzk4OTEyXSwgWyJ0MDA0NSIsIDAuNDg4NjI3ODY1XSwgWyJ0MDA0NiIsIDAuNTQ0NDA4NTkzXSwgWyJ0MDA0NyIsIDAuNDcxNDM0NTM1XSwgWyJ0MDA0OCIsIDAuNTA5OTk4NTY4XV19XV19XSwgWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC41MDI2NDc4OF0sIFsidDAwMDIiLCAwLjU1ODE5MDUyNV0sIFsidDAwMDMiLCAwLjU2MzU5MDAzNF0sIFsidDAwMDQiLCAwLjUwODE2MTE5NF0sIFsidDAwMDUiLCAwLjQ1NTkyNjQ1XSwgWyJ0MDAwNiIsIDAuNTUyNTgxMjUyXSwgWyJ0MDAwNyIsIDAuNDU5MzgwNzhdLCBbInQwMDA4IiwgMC40OTk4MTc3NjVdLCBbInQwMDA5IiwgMC41Njc1NjA4NDhdLCBbInQwMDEwIiwgMC41MTQzNzM4XSwgWyJ0MDAxMSIsIDAuNDU3OTczNjI5XSwgWyJ0MDAxMiIsIDAuNDk5MjU4Mzc4XSwgWyJ0MDAxMyIsIDAuNDkyOTg2Mzc4XSwgWyJ0MDAxNCIsIDAuNDM4ODgyMzM3XSwgWyJ0MDAxNSIsIDAuNDc4MTQxNTg1XSwgWyJ0MDAxNiIsIDAuNDkwNDk4OTQ4XSwgWyJ0MDAxNyIsIDAuNDUwOTI2MTQ2XSwgWyJ0MDAxOCIsIDAuNDkzNzc0MTU1XSwgWyJ0MDAxOSIsIDAuNDc4NjA1MTc4XSwgWyJ0MDAyMCIsIDAuNTQ0MTg2MTQ0XSwgWyJ0MDAyMSIsIDAuNDI4NjczNjIyXSwgWyJ0MDAyMiIsIDAuNDk0MDEyMThdLCBbInQwMDIzIiwgMC41NDc0Nzk2NTJdLCBbInQwMDI0IiwgMC40Mzc3MTU5NzNdLCBbInQwMDI1IiwgMC40NjM3Mjk5OTddLCBbInQwMDI2IiwgMC41MzkzODQ3MTddLCBbInQwMDI3IiwgMC41MzEzNDU5MDNdLCBbInQwMDI4IiwgMC40NzY2MTE3ODVdLCBbInQwMDI5IiwgMC41NzE1OTg4NzhdLCBbInQwMDMwIiwgMC41MTUwMDQ0MDNdLCBbInQwMDMxIiwgMC41NDE3ODg1MjldLCBbInQwMDMyIiwgMC40MzEyNzAyNjddLCBbInQwMDMzIiwgMC41MjUxMDg4NjRdLCBbInQwMDM0IiwgMC40NTUyOTY2MTJdLCBbInQwMDM1IiwgMC41NTU0MTk2NjNdLCBbInQwMDM2IiwgMC40OTYzODI3MDldLCBbInQwMDM3IiwgMC40MzQ3NjM1NzRdLCBbInQwMDM4IiwgMC40NzE4NjM1NzFdLCBbInQwMDM5IiwgMC40ODA3MjM3MzRdLCBbInQwMDQwIiwgMC40OTEyNDY0NTRdLCBbInQwMDQxIiwgMC41NTA5NjIzMDRdLCBbInQwMDQyIiwgMC40ODk4NjAxMTJdLCBbInQwMDQzIiwgMC40MTI5NDcxXSwgWyJ0MDA0NCIsIDAuNDE0NzQ4MDZdLCBbInQwMDQ1IiwgMC40ODkyOTE3MzddLCBbInQwMDQ2IiwgMC40OTkyMzI4MTFdLCBbInQwMDQ3IiwgMC40Njg4NjY3MzddLCBbInQwMDQ4IiwgMC40ODA1NDY4MTldXX1dXX1dLCBbInVwcGVyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuOTMwNjkyMjQ2XSwgWyJ0MDAwMiIsIDAuODg0MDI0OTQxXSwgWyJ0MDAwMyIsIDAuODg1MzQ0MjQzXSwgWyJ0MDAwNCIsIDAuODgxNTA5ODQyXSwgWyJ0MDAwNSIsIDAuOTUxODk5MTE3XSwgWyJ0MDAwNiIsIDAuODYwOTM1ODY1XSwgWyJ0MDAwNyIsIDAuODE3NjExNzRdLCBbInQwMDA4IiwgMC45MTczMjYxNjhdLCBbInQwMDA5IiwgMC44MDU5MTc4Ml0sIFsidDAwMTAiLCAwLjg5ODI5MjA2Nl0sIFsidDAwMTEiLCAwLjkwMzQxMDA2MV0sIFsidDAwMTIiLCAwLjkyMTcxNTgwOV0sIFsidDAwMTMiLCAwLjg4NDAyMjM2Nl0sIFsidDAwMTQiLCAwLjk3NDQ3MzY4M10sIFsidDAwMTUiLCAwLjkzNzQ2MTIxOV0sIFsidDAwMTYiLCAwLjg2OTA5NjM4XSwgWyJ0MDAxNyIsIDAuOTM1NjQ3Mjg3XSwgWyJ0MDAxOCIsIDAuOTM1MDk0NjU4XSwgWyJ0MDAxOSIsIDAuOTMyNDM4MDY5XSwgWyJ0MDAyMCIsIDAuOTg2NzgyMjUyXSwgWyJ0MDAyMSIsIDAuODk1MDUxMTE4XSwgWyJ0MDAyMiIsIDAuODI3NzM4NzkzXSwgWyJ0MDAyMyIsIDAuODM5NzgzMzM3XSwgWyJ0MDAyNCIsIDAuODg0OTgyNTU3XSwgWyJ0MDAyNSIsIDAuODM4MzI0MzI4XSwgWyJ0MDAyNiIsIDAuOTA0MzcxNzldLCBbInQwMDI3IiwgMC45MzEwNzY1XSwgWyJ0MDAyOCIsIDAuOTExMTQzOTY4XSwgWyJ0MDAyOSIsIDAuOTEzNzEwNzhdLCBbInQwMDMwIiwgMC44NzYyNzA0MzZdLCBbInQwMDMxIiwgMC45MzMwNzA3ODldLCBbInQwMDMyIiwgMC45NDk0MzUyODRdLCBbInQwMDMzIiwgMC45MDk0NzUxMDddLCBbInQwMDM0IiwgMC45NDcwOTkyN10sIFsidDAwMzUiLCAwLjkxNzEzNDkxOV0sIFsidDAwMzYiLCAwLjg5NzI2MjcxMl0sIFsidDAwMzciLCAwLjkyMTA3NzQyN10sIFsidDAwMzgiLCAwLjkwNjg4ODIzNF0sIFsidDAwMzkiLCAwLjg3MjIyNjI2Ml0sIFsidDAwNDAiLCAwLjk0MTg2Nzg4N10sIFsidDAwNDEiLCAwLjkyMzUyNzQzNl0sIFsidDAwNDIiLCAwLjg4NzI3NDc2N10sIFsidDAwNDMiLCAwLjg1ODUzMTMzNF0sIFsidDAwNDQiLCAwLjg5NjA3NTQ5OF0sIFsidDAwNDUiLCAwLjg1MTM3OTExNl0sIFsidDAwNDYiLCAwLjk2NDE4MDE5MV0sIFsidDAwNDciLCAwLjkwOTg3OTM0Nl0sIFsidDAwNDgiLCAwLjg0NTI4NjMyMl1dfV1dfV0sIFsibG93ZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC4xODM0NjYxODRdLCBbInQwMDAyIiwgMC4yNDE0NDg1NDldLCBbInQwMDAzIiwgMC4yNjY5MjAwMTZdLCBbInQwMDA0IiwgMC4yMDI0MjY0ODddLCBbInQwMDA1IiwgMC4xNzQwOTU2NzZdLCBbInQwMDA2IiwgMC4xNTA0NTUxMThdLCBbInQwMDA3IiwgMC4xNjczMzg5MDddLCBbInQwMDA4IiwgMC4yMTU1OTJdLCBbInQwMDA5IiwgMC4xNTM0MDA3NzhdLCBbInQwMDEwIiwgMC4yNjE0MjE4NDJdLCBbInQwMDExIiwgMC4yNDgwNjc2MV0sIFsidDAwMTIiLCAwLjE4ODU3Mjg3OF0sIFsidDAwMTMiLCAwLjIwMDU1OTgxM10sIFsidDAwMTQiLCAwLjI0NjAzMzI5NF0sIFsidDAwMTUiLCAwLjIwMTYyOTI1OF0sIFsidDAwMTYiLCAwLjIwMjE0NDEzMl0sIFsidDAwMTciLCAwLjIyNzE1MjVdLCBbInQwMDE4IiwgMC4xOTMwNDUyMTNdLCBbInQwMDE5IiwgMC4xNDY1NTA0MDddLCBbInQwMDIwIiwgMC4xNjU3MjExMThdLCBbInQwMDIxIiwgMC4xNDQ3OTk3MTddLCBbInQwMDIyIiwgMC4xMjcyMzc0MV0sIFsidDAwMjMiLCAwLjE3MzIzNjY4NV0sIFsidDAwMjQiLCAwLjEyNTkwMDkyMl0sIFsidDAwMjUiLCAwLjE0NzM1MjczOF0sIFsidDAwMjYiLCAwLjE3MjcxNjM1NV0sIFsidDAwMjciLCAwLjE5OTg3MzA3Nl0sIFsidDAwMjgiLCAwLjIwNTAzODg1NV0sIFsidDAwMjkiLCAwLjE4MDY2MjA4XSwgWyJ0MDAzMCIsIDAuMTkzMzI1ODQzXSwgWyJ0MDAzMSIsIDAuMjE3MTE0MjExXSwgWyJ0MDAzMiIsIDAuMTM2ODUwNDQxXSwgWyJ0MDAzMyIsIDAuMTgwMDI5Mzc3XSwgWyJ0MDAzNCIsIDAuMjA3NjUwMDVdLCBbInQwMDM1IiwgMC4yMjEyOTUwOTZdLCBbInQwMDM2IiwgMC4yMDA5MDQ5MThdLCBbInQwMDM3IiwgMC4yMDYyOTM1MDNdLCBbInQwMDM4IiwgMC4xMzg1NTA3N10sIFsidDAwMzkiLCAwLjE5MTA4ODc5XSwgWyJ0MDA0MCIsIDAuMTI0ODA3MTEyXSwgWyJ0MDA0MSIsIDAuMTcxMDAzNDA0XSwgWyJ0MDA0MiIsIDAuMjc3NDE5NzcyXSwgWyJ0MDA0MyIsIDAuMjQ5MzAxMjVdLCBbInQwMDQ0IiwgMC4yMjY1NjY1NDJdLCBbInQwMDQ1IiwgMC4xMzMwMjUyMjJdLCBbInQwMDQ2IiwgMC4yMzg1NzU0NDFdLCBbInQwMDQ3IiwgMC4yMzUyMjA2NTddLCBbInQwMDQ4IiwgMC4yMTM2NjYxMDhdXX1dXX1dXX0=", + "map" + ], + "2day" + ], + [ + "profile", + "wind", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjVdLCBbInQwMDAyIiwgMC40NTk4NDg1MDhdLCBbInQwMDAzIiwgMC40MTQ3MzU1MjFdLCBbInQwMDA0IiwgMC4zODgwODI5MjJdLCBbInQwMDA1IiwgMC4zNTI1MzIxNTZdLCBbInQwMDA2IiwgMC4zOTgyOTE0MDVdLCBbInQwMDA3IiwgMC40MDc1ODg1MDNdLCBbInQwMDA4IiwgMC40Njk0NTkyNjRdLCBbInQwMDA5IiwgMC40NTIyNTA4MTNdLCBbInQwMDEwIiwgMC4zOTAzNTUzNDFdLCBbInQwMDExIiwgMC4zOTgzNjc3NzhdLCBbInQwMDEyIiwgMC4zOTE1NTY3OTddLCBbInQwMDEzIiwgMC40MDA0NTA4M10sIFsidDAwMTQiLCAwLjM5NzM5MjU5N10sIFsidDAwMTUiLCAwLjM0MzQzMzA2NV0sIFsidDAwMTYiLCAwLjM2Nzk1ODU5OV0sIFsidDAwMTciLCAwLjM4ODUxMzAwN10sIFsidDAwMTgiLCAwLjM5ODMwNzIzMV0sIFsidDAwMTkiLCAwLjQzMTA5MzUxN10sIFsidDAwMjAiLCAwLjQ2MTU3NTAzNl0sIFsidDAwMjEiLCAwLjQ2MDQ0NjI3N10sIFsidDAwMjIiLCAwLjQ0NTI3NDU4MV0sIFsidDAwMjMiLCAwLjUzNzgyODcyNF0sIFsidDAwMjQiLCAwLjUwMDg4MzA4NV0sIFsidDAwMjUiLCAwLjU3MDIyMTE0Nl0sIFsidDAwMjYiLCAwLjUxODc4NzIxMl0sIFsidDAwMjciLCAwLjQ1NDE5Nzc0Nl0sIFsidDAwMjgiLCAwLjM2MjcxOTc1XSwgWyJ0MDAyOSIsIDAuNDAxOTM1MTkxXSwgWyJ0MDAzMCIsIDAuMzgzMzQxNjIyXSwgWyJ0MDAzMSIsIDAuMzkyNDUyODA0XSwgWyJ0MDAzMiIsIDAuMzcwNjk0NzY0XSwgWyJ0MDAzMyIsIDAuMzgxNzc5MzZdLCBbInQwMDM0IiwgMC40NjMyMDIxNzldLCBbInQwMDM1IiwgMC41MjY4MTc5MjVdLCBbInQwMDM2IiwgMC41NDg4MDYyNTddLCBbInQwMDM3IiwgMC41NjQ3NTE5NjNdLCBbInQwMDM4IiwgMC41NjU5ODAyMjddLCBbInQwMDM5IiwgMC41Mzc0MTc0MTVdLCBbInQwMDQwIiwgMC41ODUzMjYxMjZdLCBbInQwMDQxIiwgMC41NTUzMjAxNTVdLCBbInQwMDQyIiwgMC41MTAxOTE5NjddLCBbInQwMDQzIiwgMC40NDAyNDAyNDVdLCBbInQwMDQ0IiwgMC40OTkzOTg5MTJdLCBbInQwMDQ1IiwgMC40ODg2Mjc4NjVdLCBbInQwMDQ2IiwgMC41NDQ0MDg1OTNdLCBbInQwMDQ3IiwgMC40NzE0MzQ1MzVdLCBbInQwMDQ4IiwgMC41MDk5OTg1NjhdLCBbInQwMDQ5IiwgMC40NzM2ODIyOTldLCBbInQwMDUwIiwgMC40NjcwNjE2NDVdLCBbInQwMDUxIiwgMC40NTc5ODAwMDFdLCBbInQwMDUyIiwgMC40NjE3MzYzMzZdLCBbInQwMDUzIiwgMC40OTg3NTQ1MjhdLCBbInQwMDU0IiwgMC41NTM1NDUyMTNdLCBbInQwMDU1IiwgMC41MDQwNDA3OV0sIFsidDAwNTYiLCAwLjUzMTQ0NTY0OV0sIFsidDAwNTciLCAwLjU1NjE2OTY4Nl0sIFsidDAwNTgiLCAwLjUzOTQwNTMxMV0sIFsidDAwNTkiLCAwLjUzNTQ5NDk0NV0sIFsidDAwNjAiLCAwLjUyNDE2NDE1OF0sIFsidDAwNjEiLCAwLjQ0NTI1MDU2XSwgWyJ0MDA2MiIsIDAuNTAyMDIxNTk1XSwgWyJ0MDA2MyIsIDAuNDM5NDc5MTQ0XSwgWyJ0MDA2NCIsIDAuNDA2NzEwODc4XSwgWyJ0MDA2NSIsIDAuNDY3MTkzNjJdLCBbInQwMDY2IiwgMC40ODg3MjU2MzFdLCBbInQwMDY3IiwgMC41Mzk3Njc3NjZdLCBbInQwMDY4IiwgMC41NjYyNDExMzRdLCBbInQwMDY5IiwgMC41NjIxMTg5NzVdLCBbInQwMDcwIiwgMC41ODU1MTIwMjRdLCBbInQwMDcxIiwgMC42MjQwOTA1NF0sIFsidDAwNzIiLCAwLjU3MDQzNDM4Nl0sIFsidDAwNzMiLCAwLjU2MTk4OTg1NV0sIFsidDAwNzQiLCAwLjU2MDc2OTQ1Ml0sIFsidDAwNzUiLCAwLjQ4NDg4MDIzNl0sIFsidDAwNzYiLCAwLjQ4NDU5MzA0Ml0sIFsidDAwNzciLCAwLjQ5OTI5NjExNV0sIFsidDAwNzgiLCAwLjUzMjc3Nzc4N10sIFsidDAwNzkiLCAwLjYyNTI0ODk0XSwgWyJ0MDA4MCIsIDAuNjI2MTcyMzc2XSwgWyJ0MDA4MSIsIDAuNjE5NDc5MTUxXSwgWyJ0MDA4MiIsIDAuNTEwMzg5NDQyXSwgWyJ0MDA4MyIsIDAuNTMxNjA1MTY0XSwgWyJ0MDA4NCIsIDAuNTg2Nzc4OTM3XSwgWyJ0MDA4NSIsIDAuNTQ5ODIxMTAyXSwgWyJ0MDA4NiIsIDAuNTUyNjk4MjA2XSwgWyJ0MDA4NyIsIDAuNTI4Mjg4MTk0XSwgWyJ0MDA4OCIsIDAuNTQ5OTA2OTI5XSwgWyJ0MDA4OSIsIDAuNTYwNjI0MDAyXSwgWyJ0MDA5MCIsIDAuNjAzNTgxMDY1XSwgWyJ0MDA5MSIsIDAuNTQyNjkzNzkzXSwgWyJ0MDA5MiIsIDAuNTU4MTQ2MTA2XSwgWyJ0MDA5MyIsIDAuNTQzMDM1MjU4XSwgWyJ0MDA5NCIsIDAuNTM4NTcyMzldLCBbInQwMDk1IiwgMC40ODQ2ODcyNjRdLCBbInQwMDk2IiwgMC40Nzg3MDM2NDddLCBbInQwMDk3IiwgMC41NDQwMDA3ODJdLCBbInQwMDk4IiwgMC41MTM4OTM1NThdLCBbInQwMDk5IiwgMC41NDE3NTc3ODVdLCBbInQwMTAwIiwgMC41MTM5NjMwMzJdLCBbInQwMTAxIiwgMC41MTk3NjY1NjJdLCBbInQwMTAyIiwgMC42MTIwODg1MjhdLCBbInQwMTAzIiwgMC42MjA0Njg0OTddLCBbInQwMTA0IiwgMC41NTcyMzkzNjZdLCBbInQwMTA1IiwgMC41ODc3Njg3MTldLCBbInQwMTA2IiwgMC41NzA3ODI0MzhdLCBbInQwMTA3IiwgMC41ODE2ODA5NDNdLCBbInQwMTA4IiwgMC41NzQ4MTMzMTJdLCBbInQwMTA5IiwgMC41NzA0MzY2NjVdLCBbInQwMTEwIiwgMC41ODYxNTQ1MThdLCBbInQwMTExIiwgMC41NDQ3Mzg3MjddLCBbInQwMTEyIiwgMC41MzAzMzE1NTNdLCBbInQwMTEzIiwgMC41MjAyODAzMTVdLCBbInQwMTE0IiwgMC40NTEzMTgyNTldLCBbInQwMTE1IiwgMC40MjI1MzA2OF0sIFsidDAxMTYiLCAwLjQ0ODEwOTgwNl0sIFsidDAxMTciLCAwLjQ4NzM1OTQyMV0sIFsidDAxMTgiLCAwLjQ4MTIyMTM5M10sIFsidDAxMTkiLCAwLjUzNjc4NTI1OV0sIFsidDAxMjAiLCAwLjUzMjM1MzAyXSwgWyJ0MDEyMSIsIDAuNDQ5NTI4MzEzXSwgWyJ0MDEyMiIsIDAuNTIyOTgyMjQyXSwgWyJ0MDEyMyIsIDAuNDk0NDcyOTE3XSwgWyJ0MDEyNCIsIDAuNDUxOTA0MzI0XSwgWyJ0MDEyNSIsIDAuNDczOTc0OTE1XSwgWyJ0MDEyNiIsIDAuNDcwMzIxNjU3XSwgWyJ0MDEyNyIsIDAuNTIzMjQ5MjQyXSwgWyJ0MDEyOCIsIDAuNTExOTUzOTQzXSwgWyJ0MDEyOSIsIDAuNTY5NDcyNDNdLCBbInQwMTMwIiwgMC41MzcwODU2N10sIFsidDAxMzEiLCAwLjU4MDMyNDAyNV0sIFsidDAxMzIiLCAwLjU3MzY3MDczOV0sIFsidDAxMzMiLCAwLjU2NjkxNzk1N10sIFsidDAxMzQiLCAwLjU0NzUyNjA3N10sIFsidDAxMzUiLCAwLjY0MTA2NDAzNF0sIFsidDAxMzYiLCAwLjYzMDA1MDEwMl0sIFsidDAxMzciLCAwLjU1MzQ4MjUwOF0sIFsidDAxMzgiLCAwLjU0MDczOTIxNV0sIFsidDAxMzkiLCAwLjU3MDEwMzQyXSwgWyJ0MDE0MCIsIDAuNTQxMzkyMTU1XSwgWyJ0MDE0MSIsIDAuNDg5MDkyNTc5XSwgWyJ0MDE0MiIsIDAuNDUyNjE3MTI4XSwgWyJ0MDE0MyIsIDAuNDE1NjMxMzk5XSwgWyJ0MDE0NCIsIDAuNDEwNjU0MzQxXSwgWyJ0MDE0NSIsIDAuNTIzOTA0ODM3XSwgWyJ0MDE0NiIsIDAuNDQ5MjM1OTIzXSwgWyJ0MDE0NyIsIDAuNDYwMTk1ODUyXSwgWyJ0MDE0OCIsIDAuNTQ5ODE3MDE3XSwgWyJ0MDE0OSIsIDAuNTU5NTQ2MDk0XSwgWyJ0MDE1MCIsIDAuNTI2MTkxODY5XSwgWyJ0MDE1MSIsIDAuNTcwNzUzNjQ3XSwgWyJ0MDE1MiIsIDAuNTUyMTA2MjZdLCBbInQwMTUzIiwgMC41OTE4Njc4MTJdLCBbInQwMTU0IiwgMC41NTA5Njc3NjldLCBbInQwMTU1IiwgMC41NTA3NDI5MzVdLCBbInQwMTU2IiwgMC41NDA0NTE3Ml0sIFsidDAxNTciLCAwLjUwMzczMjQ1N10sIFsidDAxNTgiLCAwLjQ2ODY5OTA0NF0sIFsidDAxNTkiLCAwLjUyMzIxMTYwOV0sIFsidDAxNjAiLCAwLjYxNjM1MjI5Nl0sIFsidDAxNjEiLCAwLjY1MjY1MTg4XSwgWyJ0MDE2MiIsIDAuNjMyMTcwODY3XSwgWyJ0MDE2MyIsIDAuNjUwMTA5Njk3XSwgWyJ0MDE2NCIsIDAuNjAyNTIyNjM1XSwgWyJ0MDE2NSIsIDAuNTIxMDk2MTc3XSwgWyJ0MDE2NiIsIDAuNTIxODYwMDY0XSwgWyJ0MDE2NyIsIDAuNTMxOTMxMTMxXSwgWyJ0MDE2OCIsIDAuNjQxMDc5M11dfQ==", + "map" + ], + "system" + ], + [ + "solve", + "1week_rolling", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsICIxd2VlayJdXX0=", + "map" + ], + "1week_rolling" + ], + [ + "solve", + "1week_rolling", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwZXJpb2QxIl19", + "array" + ], + "1week_rolling" + ], + [ + "solve", + "1week_rolling", + "rolling_solve_horizon", + [ + "OTY=", + "float" + ], + "1week_rolling" + ], + [ + "solve", + "1week_rolling", + "rolling_solve_jump", + [ + "MjQ=", + "float" + ], + "1week_rolling" + ], + [ + "solve", + "1week_rolling", + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ], + "1week_rolling" + ], + [ + "solve", + "1week_rolling", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dLCBbInQwMDI1IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sieWVzIiwgMS4wXV19XSwgWyJ0MDA0OSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV0sIFsidDAwNzMiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dLCBbInQwMDk3IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sieWVzIiwgMS4wXV19XSwgWyJ0MDEyMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV0sIFsidDAxNDUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dXX1dLCBbInVwcGVyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAwMjUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAwNDkiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAwNzMiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAwOTciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAxMjEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV0sIFsidDAxNDUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93ZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDAyNSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA0OSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA3MyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA5NyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDEyMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDE0NSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDAyNSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA0OSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA3MyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDA5NyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDEyMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XSwgWyJ0MDE0NSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XV19XV19", + "map" + ], + "1week_rolling" + ], + [ + "solve", + "2day_dispatch", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsICIyZGF5Il1dfQ==", + "map" + ], + "2day" + ], + [ + "solve", + "2day_dispatch", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwZXJpb2QxIl19", + "array" + ], + "2day" + ], + [ + "solve", + "2day_dispatch", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "2day" + ], + [ + "solve", + "2day_dispatch", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dXX1dLCBbInVwcGVyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93ZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XV19XV19", + "map" + ], + "2day" + ], + [ + "timeline", + "y2020", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF0sIFsidDAwMDciLCAxLjBdLCBbInQwMDA4IiwgMS4wXSwgWyJ0MDAwOSIsIDEuMF0sIFsidDAwMTAiLCAxLjBdLCBbInQwMDExIiwgMS4wXSwgWyJ0MDAxMiIsIDEuMF0sIFsidDAwMTMiLCAxLjBdLCBbInQwMDE0IiwgMS4wXSwgWyJ0MDAxNSIsIDEuMF0sIFsidDAwMTYiLCAxLjBdLCBbInQwMDE3IiwgMS4wXSwgWyJ0MDAxOCIsIDEuMF0sIFsidDAwMTkiLCAxLjBdLCBbInQwMDIwIiwgMS4wXSwgWyJ0MDAyMSIsIDEuMF0sIFsidDAwMjIiLCAxLjBdLCBbInQwMDIzIiwgMS4wXSwgWyJ0MDAyNCIsIDEuMF0sIFsidDAwMjUiLCAxLjBdLCBbInQwMDI2IiwgMS4wXSwgWyJ0MDAyNyIsIDEuMF0sIFsidDAwMjgiLCAxLjBdLCBbInQwMDI5IiwgMS4wXSwgWyJ0MDAzMCIsIDEuMF0sIFsidDAwMzEiLCAxLjBdLCBbInQwMDMyIiwgMS4wXSwgWyJ0MDAzMyIsIDEuMF0sIFsidDAwMzQiLCAxLjBdLCBbInQwMDM1IiwgMS4wXSwgWyJ0MDAzNiIsIDEuMF0sIFsidDAwMzciLCAxLjBdLCBbInQwMDM4IiwgMS4wXSwgWyJ0MDAzOSIsIDEuMF0sIFsidDAwNDAiLCAxLjBdLCBbInQwMDQxIiwgMS4wXSwgWyJ0MDA0MiIsIDEuMF0sIFsidDAwNDMiLCAxLjBdLCBbInQwMDQ0IiwgMS4wXSwgWyJ0MDA0NSIsIDEuMF0sIFsidDAwNDYiLCAxLjBdLCBbInQwMDQ3IiwgMS4wXSwgWyJ0MDA0OCIsIDEuMF0sIFsidDAwNDkiLCAxLjBdLCBbInQwMDUwIiwgMS4wXSwgWyJ0MDA1MSIsIDEuMF0sIFsidDAwNTIiLCAxLjBdLCBbInQwMDUzIiwgMS4wXSwgWyJ0MDA1NCIsIDEuMF0sIFsidDAwNTUiLCAxLjBdLCBbInQwMDU2IiwgMS4wXSwgWyJ0MDA1NyIsIDEuMF0sIFsidDAwNTgiLCAxLjBdLCBbInQwMDU5IiwgMS4wXSwgWyJ0MDA2MCIsIDEuMF0sIFsidDAwNjEiLCAxLjBdLCBbInQwMDYyIiwgMS4wXSwgWyJ0MDA2MyIsIDEuMF0sIFsidDAwNjQiLCAxLjBdLCBbInQwMDY1IiwgMS4wXSwgWyJ0MDA2NiIsIDEuMF0sIFsidDAwNjciLCAxLjBdLCBbInQwMDY4IiwgMS4wXSwgWyJ0MDA2OSIsIDEuMF0sIFsidDAwNzAiLCAxLjBdLCBbInQwMDcxIiwgMS4wXSwgWyJ0MDA3MiIsIDEuMF0sIFsidDAwNzMiLCAxLjBdLCBbInQwMDc0IiwgMS4wXSwgWyJ0MDA3NSIsIDEuMF0sIFsidDAwNzYiLCAxLjBdLCBbInQwMDc3IiwgMS4wXSwgWyJ0MDA3OCIsIDEuMF0sIFsidDAwNzkiLCAxLjBdLCBbInQwMDgwIiwgMS4wXSwgWyJ0MDA4MSIsIDEuMF0sIFsidDAwODIiLCAxLjBdLCBbInQwMDgzIiwgMS4wXSwgWyJ0MDA4NCIsIDEuMF0sIFsidDAwODUiLCAxLjBdLCBbInQwMDg2IiwgMS4wXSwgWyJ0MDA4NyIsIDEuMF0sIFsidDAwODgiLCAxLjBdLCBbInQwMDg5IiwgMS4wXSwgWyJ0MDA5MCIsIDEuMF0sIFsidDAwOTEiLCAxLjBdLCBbInQwMDkyIiwgMS4wXSwgWyJ0MDA5MyIsIDEuMF0sIFsidDAwOTQiLCAxLjBdLCBbInQwMDk1IiwgMS4wXSwgWyJ0MDA5NiIsIDEuMF0sIFsidDAwOTciLCAxLjBdLCBbInQwMDk4IiwgMS4wXSwgWyJ0MDA5OSIsIDEuMF0sIFsidDAxMDAiLCAxLjBdLCBbInQwMTAxIiwgMS4wXSwgWyJ0MDEwMiIsIDEuMF0sIFsidDAxMDMiLCAxLjBdLCBbInQwMTA0IiwgMS4wXSwgWyJ0MDEwNSIsIDEuMF0sIFsidDAxMDYiLCAxLjBdLCBbInQwMTA3IiwgMS4wXSwgWyJ0MDEwOCIsIDEuMF0sIFsidDAxMDkiLCAxLjBdLCBbInQwMTEwIiwgMS4wXSwgWyJ0MDExMSIsIDEuMF0sIFsidDAxMTIiLCAxLjBdLCBbInQwMTEzIiwgMS4wXSwgWyJ0MDExNCIsIDEuMF0sIFsidDAxMTUiLCAxLjBdLCBbInQwMTE2IiwgMS4wXSwgWyJ0MDExNyIsIDEuMF0sIFsidDAxMTgiLCAxLjBdLCBbInQwMTE5IiwgMS4wXSwgWyJ0MDEyMCIsIDEuMF0sIFsidDAxMjEiLCAxLjBdLCBbInQwMTIyIiwgMS4wXSwgWyJ0MDEyMyIsIDEuMF0sIFsidDAxMjQiLCAxLjBdLCBbInQwMTI1IiwgMS4wXSwgWyJ0MDEyNiIsIDEuMF0sIFsidDAxMjciLCAxLjBdLCBbInQwMTI4IiwgMS4wXSwgWyJ0MDEyOSIsIDEuMF0sIFsidDAxMzAiLCAxLjBdLCBbInQwMTMxIiwgMS4wXSwgWyJ0MDEzMiIsIDEuMF0sIFsidDAxMzMiLCAxLjBdLCBbInQwMTM0IiwgMS4wXSwgWyJ0MDEzNSIsIDEuMF0sIFsidDAxMzYiLCAxLjBdLCBbInQwMTM3IiwgMS4wXSwgWyJ0MDEzOCIsIDEuMF0sIFsidDAxMzkiLCAxLjBdLCBbInQwMTQwIiwgMS4wXSwgWyJ0MDE0MSIsIDEuMF0sIFsidDAxNDIiLCAxLjBdLCBbInQwMTQzIiwgMS4wXSwgWyJ0MDE0NCIsIDEuMF0sIFsidDAxNDUiLCAxLjBdLCBbInQwMTQ2IiwgMS4wXSwgWyJ0MDE0NyIsIDEuMF0sIFsidDAxNDgiLCAxLjBdLCBbInQwMTQ5IiwgMS4wXSwgWyJ0MDE1MCIsIDEuMF0sIFsidDAxNTEiLCAxLjBdLCBbInQwMTUyIiwgMS4wXSwgWyJ0MDE1MyIsIDEuMF0sIFsidDAxNTQiLCAxLjBdLCBbInQwMTU1IiwgMS4wXSwgWyJ0MDE1NiIsIDEuMF0sIFsidDAxNTciLCAxLjBdLCBbInQwMTU4IiwgMS4wXSwgWyJ0MDE1OSIsIDEuMF0sIFsidDAxNjAiLCAxLjBdLCBbInQwMTYxIiwgMS4wXSwgWyJ0MDE2MiIsIDEuMF0sIFsidDAxNjMiLCAxLjBdLCBbInQwMTY0IiwgMS4wXSwgWyJ0MDE2NSIsIDEuMF0sIFsidDAxNjYiLCAxLjBdLCBbInQwMTY3IiwgMS4wXSwgWyJ0MDE2OCIsIDEuMF0sIFsidDAxNjkiLCAxLjBdLCBbInQwMTcwIiwgMS4wXSwgWyJ0MDE3MSIsIDEuMF0sIFsidDAxNzIiLCAxLjBdLCBbInQwMTczIiwgMS4wXSwgWyJ0MDE3NCIsIDEuMF0sIFsidDAxNzUiLCAxLjBdLCBbInQwMTc2IiwgMS4wXSwgWyJ0MDE3NyIsIDEuMF0sIFsidDAxNzgiLCAxLjBdLCBbInQwMTc5IiwgMS4wXSwgWyJ0MDE4MCIsIDEuMF0sIFsidDAxODEiLCAxLjBdLCBbInQwMTgyIiwgMS4wXSwgWyJ0MDE4MyIsIDEuMF0sIFsidDAxODQiLCAxLjBdLCBbInQwMTg1IiwgMS4wXSwgWyJ0MDE4NiIsIDEuMF0sIFsidDAxODciLCAxLjBdLCBbInQwMTg4IiwgMS4wXSwgWyJ0MDE4OSIsIDEuMF0sIFsidDAxOTAiLCAxLjBdLCBbInQwMTkxIiwgMS4wXSwgWyJ0MDE5MiIsIDEuMF0sIFsidDAxOTMiLCAxLjBdLCBbInQwMTk0IiwgMS4wXSwgWyJ0MDE5NSIsIDEuMF0sIFsidDAxOTYiLCAxLjBdLCBbInQwMTk3IiwgMS4wXSwgWyJ0MDE5OCIsIDEuMF0sIFsidDAxOTkiLCAxLjBdLCBbInQwMjAwIiwgMS4wXSwgWyJ0MDIwMSIsIDEuMF0sIFsidDAyMDIiLCAxLjBdLCBbInQwMjAzIiwgMS4wXSwgWyJ0MDIwNCIsIDEuMF0sIFsidDAyMDUiLCAxLjBdLCBbInQwMjA2IiwgMS4wXSwgWyJ0MDIwNyIsIDEuMF0sIFsidDAyMDgiLCAxLjBdLCBbInQwMjA5IiwgMS4wXSwgWyJ0MDIxMCIsIDEuMF0sIFsidDAyMTEiLCAxLjBdLCBbInQwMjEyIiwgMS4wXSwgWyJ0MDIxMyIsIDEuMF0sIFsidDAyMTQiLCAxLjBdLCBbInQwMjE1IiwgMS4wXSwgWyJ0MDIxNiIsIDEuMF0sIFsidDAyMTciLCAxLjBdLCBbInQwMjE4IiwgMS4wXSwgWyJ0MDIxOSIsIDEuMF0sIFsidDAyMjAiLCAxLjBdLCBbInQwMjIxIiwgMS4wXSwgWyJ0MDIyMiIsIDEuMF0sIFsidDAyMjMiLCAxLjBdLCBbInQwMjI0IiwgMS4wXSwgWyJ0MDIyNSIsIDEuMF0sIFsidDAyMjYiLCAxLjBdLCBbInQwMjI3IiwgMS4wXSwgWyJ0MDIyOCIsIDEuMF0sIFsidDAyMjkiLCAxLjBdLCBbInQwMjMwIiwgMS4wXSwgWyJ0MDIzMSIsIDEuMF0sIFsidDAyMzIiLCAxLjBdLCBbInQwMjMzIiwgMS4wXSwgWyJ0MDIzNCIsIDEuMF0sIFsidDAyMzUiLCAxLjBdLCBbInQwMjM2IiwgMS4wXSwgWyJ0MDIzNyIsIDEuMF0sIFsidDAyMzgiLCAxLjBdLCBbInQwMjM5IiwgMS4wXSwgWyJ0MDI0MCIsIDEuMF0sIFsidDAyNDEiLCAxLjBdLCBbInQwMjQyIiwgMS4wXSwgWyJ0MDI0MyIsIDEuMF0sIFsidDAyNDQiLCAxLjBdLCBbInQwMjQ1IiwgMS4wXSwgWyJ0MDI0NiIsIDEuMF0sIFsidDAyNDciLCAxLjBdLCBbInQwMjQ4IiwgMS4wXSwgWyJ0MDI0OSIsIDEuMF0sIFsidDAyNTAiLCAxLjBdLCBbInQwMjUxIiwgMS4wXSwgWyJ0MDI1MiIsIDEuMF0sIFsidDAyNTMiLCAxLjBdLCBbInQwMjU0IiwgMS4wXSwgWyJ0MDI1NSIsIDEuMF0sIFsidDAyNTYiLCAxLjBdLCBbInQwMjU3IiwgMS4wXSwgWyJ0MDI1OCIsIDEuMF0sIFsidDAyNTkiLCAxLjBdLCBbInQwMjYwIiwgMS4wXSwgWyJ0MDI2MSIsIDEuMF0sIFsidDAyNjIiLCAxLjBdLCBbInQwMjYzIiwgMS4wXSwgWyJ0MDI2NCIsIDEuMF0sIFsidDAyNjUiLCAxLjBdLCBbInQwMjY2IiwgMS4wXSwgWyJ0MDI2NyIsIDEuMF0sIFsidDAyNjgiLCAxLjBdLCBbInQwMjY5IiwgMS4wXSwgWyJ0MDI3MCIsIDEuMF0sIFsidDAyNzEiLCAxLjBdLCBbInQwMjcyIiwgMS4wXSwgWyJ0MDI3MyIsIDEuMF0sIFsidDAyNzQiLCAxLjBdLCBbInQwMjc1IiwgMS4wXSwgWyJ0MDI3NiIsIDEuMF0sIFsidDAyNzciLCAxLjBdLCBbInQwMjc4IiwgMS4wXSwgWyJ0MDI3OSIsIDEuMF0sIFsidDAyODAiLCAxLjBdLCBbInQwMjgxIiwgMS4wXSwgWyJ0MDI4MiIsIDEuMF0sIFsidDAyODMiLCAxLjBdLCBbInQwMjg0IiwgMS4wXSwgWyJ0MDI4NSIsIDEuMF0sIFsidDAyODYiLCAxLjBdLCBbInQwMjg3IiwgMS4wXSwgWyJ0MDI4OCIsIDEuMF0sIFsidDAyODkiLCAxLjBdLCBbInQwMjkwIiwgMS4wXSwgWyJ0MDI5MSIsIDEuMF0sIFsidDAyOTIiLCAxLjBdLCBbInQwMjkzIiwgMS4wXSwgWyJ0MDI5NCIsIDEuMF0sIFsidDAyOTUiLCAxLjBdLCBbInQwMjk2IiwgMS4wXSwgWyJ0MDI5NyIsIDEuMF0sIFsidDAyOTgiLCAxLjBdLCBbInQwMjk5IiwgMS4wXSwgWyJ0MDMwMCIsIDEuMF0sIFsidDAzMDEiLCAxLjBdLCBbInQwMzAyIiwgMS4wXSwgWyJ0MDMwMyIsIDEuMF0sIFsidDAzMDQiLCAxLjBdLCBbInQwMzA1IiwgMS4wXSwgWyJ0MDMwNiIsIDEuMF0sIFsidDAzMDciLCAxLjBdLCBbInQwMzA4IiwgMS4wXSwgWyJ0MDMwOSIsIDEuMF0sIFsidDAzMTAiLCAxLjBdLCBbInQwMzExIiwgMS4wXSwgWyJ0MDMxMiIsIDEuMF0sIFsidDAzMTMiLCAxLjBdLCBbInQwMzE0IiwgMS4wXSwgWyJ0MDMxNSIsIDEuMF0sIFsidDAzMTYiLCAxLjBdLCBbInQwMzE3IiwgMS4wXSwgWyJ0MDMxOCIsIDEuMF0sIFsidDAzMTkiLCAxLjBdLCBbInQwMzIwIiwgMS4wXSwgWyJ0MDMyMSIsIDEuMF0sIFsidDAzMjIiLCAxLjBdLCBbInQwMzIzIiwgMS4wXSwgWyJ0MDMyNCIsIDEuMF0sIFsidDAzMjUiLCAxLjBdLCBbInQwMzI2IiwgMS4wXSwgWyJ0MDMyNyIsIDEuMF0sIFsidDAzMjgiLCAxLjBdLCBbInQwMzI5IiwgMS4wXSwgWyJ0MDMzMCIsIDEuMF0sIFsidDAzMzEiLCAxLjBdLCBbInQwMzMyIiwgMS4wXSwgWyJ0MDMzMyIsIDEuMF0sIFsidDAzMzQiLCAxLjBdLCBbInQwMzM1IiwgMS4wXSwgWyJ0MDMzNiIsIDEuMF0sIFsidDAzMzciLCAxLjBdLCBbInQwMzM4IiwgMS4wXSwgWyJ0MDMzOSIsIDEuMF0sIFsidDAzNDAiLCAxLjBdLCBbInQwMzQxIiwgMS4wXSwgWyJ0MDM0MiIsIDEuMF0sIFsidDAzNDMiLCAxLjBdLCBbInQwMzQ0IiwgMS4wXSwgWyJ0MDM0NSIsIDEuMF0sIFsidDAzNDYiLCAxLjBdLCBbInQwMzQ3IiwgMS4wXSwgWyJ0MDM0OCIsIDEuMF0sIFsidDAzNDkiLCAxLjBdLCBbInQwMzUwIiwgMS4wXSwgWyJ0MDM1MSIsIDEuMF0sIFsidDAzNTIiLCAxLjBdLCBbInQwMzUzIiwgMS4wXSwgWyJ0MDM1NCIsIDEuMF0sIFsidDAzNTUiLCAxLjBdLCBbInQwMzU2IiwgMS4wXSwgWyJ0MDM1NyIsIDEuMF0sIFsidDAzNTgiLCAxLjBdLCBbInQwMzU5IiwgMS4wXSwgWyJ0MDM2MCIsIDEuMF0sIFsidDAzNjEiLCAxLjBdLCBbInQwMzYyIiwgMS4wXSwgWyJ0MDM2MyIsIDEuMF0sIFsidDAzNjQiLCAxLjBdLCBbInQwMzY1IiwgMS4wXSwgWyJ0MDM2NiIsIDEuMF0sIFsidDAzNjciLCAxLjBdLCBbInQwMzY4IiwgMS4wXSwgWyJ0MDM2OSIsIDEuMF0sIFsidDAzNzAiLCAxLjBdLCBbInQwMzcxIiwgMS4wXSwgWyJ0MDM3MiIsIDEuMF0sIFsidDAzNzMiLCAxLjBdLCBbInQwMzc0IiwgMS4wXSwgWyJ0MDM3NSIsIDEuMF0sIFsidDAzNzYiLCAxLjBdLCBbInQwMzc3IiwgMS4wXSwgWyJ0MDM3OCIsIDEuMF0sIFsidDAzNzkiLCAxLjBdLCBbInQwMzgwIiwgMS4wXSwgWyJ0MDM4MSIsIDEuMF0sIFsidDAzODIiLCAxLjBdLCBbInQwMzgzIiwgMS4wXSwgWyJ0MDM4NCIsIDEuMF0sIFsidDAzODUiLCAxLjBdLCBbInQwMzg2IiwgMS4wXSwgWyJ0MDM4NyIsIDEuMF0sIFsidDAzODgiLCAxLjBdLCBbInQwMzg5IiwgMS4wXSwgWyJ0MDM5MCIsIDEuMF0sIFsidDAzOTEiLCAxLjBdLCBbInQwMzkyIiwgMS4wXSwgWyJ0MDM5MyIsIDEuMF0sIFsidDAzOTQiLCAxLjBdLCBbInQwMzk1IiwgMS4wXSwgWyJ0MDM5NiIsIDEuMF0sIFsidDAzOTciLCAxLjBdLCBbInQwMzk4IiwgMS4wXSwgWyJ0MDM5OSIsIDEuMF0sIFsidDA0MDAiLCAxLjBdLCBbInQwNDAxIiwgMS4wXSwgWyJ0MDQwMiIsIDEuMF0sIFsidDA0MDMiLCAxLjBdLCBbInQwNDA0IiwgMS4wXSwgWyJ0MDQwNSIsIDEuMF0sIFsidDA0MDYiLCAxLjBdLCBbInQwNDA3IiwgMS4wXSwgWyJ0MDQwOCIsIDEuMF0sIFsidDA0MDkiLCAxLjBdLCBbInQwNDEwIiwgMS4wXSwgWyJ0MDQxMSIsIDEuMF0sIFsidDA0MTIiLCAxLjBdLCBbInQwNDEzIiwgMS4wXSwgWyJ0MDQxNCIsIDEuMF0sIFsidDA0MTUiLCAxLjBdLCBbInQwNDE2IiwgMS4wXSwgWyJ0MDQxNyIsIDEuMF0sIFsidDA0MTgiLCAxLjBdLCBbInQwNDE5IiwgMS4wXSwgWyJ0MDQyMCIsIDEuMF0sIFsidDA0MjEiLCAxLjBdLCBbInQwNDIyIiwgMS4wXSwgWyJ0MDQyMyIsIDEuMF0sIFsidDA0MjQiLCAxLjBdLCBbInQwNDI1IiwgMS4wXSwgWyJ0MDQyNiIsIDEuMF0sIFsidDA0MjciLCAxLjBdLCBbInQwNDI4IiwgMS4wXSwgWyJ0MDQyOSIsIDEuMF0sIFsidDA0MzAiLCAxLjBdLCBbInQwNDMxIiwgMS4wXSwgWyJ0MDQzMiIsIDEuMF0sIFsidDA0MzMiLCAxLjBdLCBbInQwNDM0IiwgMS4wXSwgWyJ0MDQzNSIsIDEuMF0sIFsidDA0MzYiLCAxLjBdLCBbInQwNDM3IiwgMS4wXSwgWyJ0MDQzOCIsIDEuMF0sIFsidDA0MzkiLCAxLjBdLCBbInQwNDQwIiwgMS4wXSwgWyJ0MDQ0MSIsIDEuMF0sIFsidDA0NDIiLCAxLjBdLCBbInQwNDQzIiwgMS4wXSwgWyJ0MDQ0NCIsIDEuMF0sIFsidDA0NDUiLCAxLjBdLCBbInQwNDQ2IiwgMS4wXSwgWyJ0MDQ0NyIsIDEuMF0sIFsidDA0NDgiLCAxLjBdLCBbInQwNDQ5IiwgMS4wXSwgWyJ0MDQ1MCIsIDEuMF0sIFsidDA0NTEiLCAxLjBdLCBbInQwNDUyIiwgMS4wXSwgWyJ0MDQ1MyIsIDEuMF0sIFsidDA0NTQiLCAxLjBdLCBbInQwNDU1IiwgMS4wXSwgWyJ0MDQ1NiIsIDEuMF0sIFsidDA0NTciLCAxLjBdLCBbInQwNDU4IiwgMS4wXSwgWyJ0MDQ1OSIsIDEuMF0sIFsidDA0NjAiLCAxLjBdLCBbInQwNDYxIiwgMS4wXSwgWyJ0MDQ2MiIsIDEuMF0sIFsidDA0NjMiLCAxLjBdLCBbInQwNDY0IiwgMS4wXSwgWyJ0MDQ2NSIsIDEuMF0sIFsidDA0NjYiLCAxLjBdLCBbInQwNDY3IiwgMS4wXSwgWyJ0MDQ2OCIsIDEuMF0sIFsidDA0NjkiLCAxLjBdLCBbInQwNDcwIiwgMS4wXSwgWyJ0MDQ3MSIsIDEuMF0sIFsidDA0NzIiLCAxLjBdLCBbInQwNDczIiwgMS4wXSwgWyJ0MDQ3NCIsIDEuMF0sIFsidDA0NzUiLCAxLjBdLCBbInQwNDc2IiwgMS4wXSwgWyJ0MDQ3NyIsIDEuMF0sIFsidDA0NzgiLCAxLjBdLCBbInQwNDc5IiwgMS4wXSwgWyJ0MDQ4MCIsIDEuMF0sIFsidDA0ODEiLCAxLjBdLCBbInQwNDgyIiwgMS4wXSwgWyJ0MDQ4MyIsIDEuMF0sIFsidDA0ODQiLCAxLjBdLCBbInQwNDg1IiwgMS4wXSwgWyJ0MDQ4NiIsIDEuMF0sIFsidDA0ODciLCAxLjBdLCBbInQwNDg4IiwgMS4wXSwgWyJ0MDQ4OSIsIDEuMF0sIFsidDA0OTAiLCAxLjBdLCBbInQwNDkxIiwgMS4wXSwgWyJ0MDQ5MiIsIDEuMF0sIFsidDA0OTMiLCAxLjBdLCBbInQwNDk0IiwgMS4wXSwgWyJ0MDQ5NSIsIDEuMF0sIFsidDA0OTYiLCAxLjBdLCBbInQwNDk3IiwgMS4wXSwgWyJ0MDQ5OCIsIDEuMF0sIFsidDA0OTkiLCAxLjBdLCBbInQwNTAwIiwgMS4wXSwgWyJ0MDUwMSIsIDEuMF0sIFsidDA1MDIiLCAxLjBdLCBbInQwNTAzIiwgMS4wXSwgWyJ0MDUwNCIsIDEuMF0sIFsidDA1MDUiLCAxLjBdLCBbInQwNTA2IiwgMS4wXSwgWyJ0MDUwNyIsIDEuMF0sIFsidDA1MDgiLCAxLjBdLCBbInQwNTA5IiwgMS4wXSwgWyJ0MDUxMCIsIDEuMF0sIFsidDA1MTEiLCAxLjBdLCBbInQwNTEyIiwgMS4wXSwgWyJ0MDUxMyIsIDEuMF0sIFsidDA1MTQiLCAxLjBdLCBbInQwNTE1IiwgMS4wXSwgWyJ0MDUxNiIsIDEuMF0sIFsidDA1MTciLCAxLjBdLCBbInQwNTE4IiwgMS4wXSwgWyJ0MDUxOSIsIDEuMF0sIFsidDA1MjAiLCAxLjBdLCBbInQwNTIxIiwgMS4wXSwgWyJ0MDUyMiIsIDEuMF0sIFsidDA1MjMiLCAxLjBdLCBbInQwNTI0IiwgMS4wXSwgWyJ0MDUyNSIsIDEuMF0sIFsidDA1MjYiLCAxLjBdLCBbInQwNTI3IiwgMS4wXSwgWyJ0MDUyOCIsIDEuMF0sIFsidDA1MjkiLCAxLjBdLCBbInQwNTMwIiwgMS4wXSwgWyJ0MDUzMSIsIDEuMF0sIFsidDA1MzIiLCAxLjBdLCBbInQwNTMzIiwgMS4wXSwgWyJ0MDUzNCIsIDEuMF0sIFsidDA1MzUiLCAxLjBdLCBbInQwNTM2IiwgMS4wXSwgWyJ0MDUzNyIsIDEuMF0sIFsidDA1MzgiLCAxLjBdLCBbInQwNTM5IiwgMS4wXSwgWyJ0MDU0MCIsIDEuMF0sIFsidDA1NDEiLCAxLjBdLCBbInQwNTQyIiwgMS4wXSwgWyJ0MDU0MyIsIDEuMF0sIFsidDA1NDQiLCAxLjBdLCBbInQwNTQ1IiwgMS4wXSwgWyJ0MDU0NiIsIDEuMF0sIFsidDA1NDciLCAxLjBdLCBbInQwNTQ4IiwgMS4wXSwgWyJ0MDU0OSIsIDEuMF0sIFsidDA1NTAiLCAxLjBdLCBbInQwNTUxIiwgMS4wXSwgWyJ0MDU1MiIsIDEuMF0sIFsidDA1NTMiLCAxLjBdLCBbInQwNTU0IiwgMS4wXSwgWyJ0MDU1NSIsIDEuMF0sIFsidDA1NTYiLCAxLjBdLCBbInQwNTU3IiwgMS4wXSwgWyJ0MDU1OCIsIDEuMF0sIFsidDA1NTkiLCAxLjBdLCBbInQwNTYwIiwgMS4wXSwgWyJ0MDU2MSIsIDEuMF0sIFsidDA1NjIiLCAxLjBdLCBbInQwNTYzIiwgMS4wXSwgWyJ0MDU2NCIsIDEuMF0sIFsidDA1NjUiLCAxLjBdLCBbInQwNTY2IiwgMS4wXSwgWyJ0MDU2NyIsIDEuMF0sIFsidDA1NjgiLCAxLjBdLCBbInQwNTY5IiwgMS4wXSwgWyJ0MDU3MCIsIDEuMF0sIFsidDA1NzEiLCAxLjBdLCBbInQwNTcyIiwgMS4wXSwgWyJ0MDU3MyIsIDEuMF0sIFsidDA1NzQiLCAxLjBdLCBbInQwNTc1IiwgMS4wXSwgWyJ0MDU3NiIsIDEuMF0sIFsidDA1NzciLCAxLjBdLCBbInQwNTc4IiwgMS4wXSwgWyJ0MDU3OSIsIDEuMF0sIFsidDA1ODAiLCAxLjBdLCBbInQwNTgxIiwgMS4wXSwgWyJ0MDU4MiIsIDEuMF0sIFsidDA1ODMiLCAxLjBdLCBbInQwNTg0IiwgMS4wXSwgWyJ0MDU4NSIsIDEuMF0sIFsidDA1ODYiLCAxLjBdLCBbInQwNTg3IiwgMS4wXSwgWyJ0MDU4OCIsIDEuMF0sIFsidDA1ODkiLCAxLjBdLCBbInQwNTkwIiwgMS4wXSwgWyJ0MDU5MSIsIDEuMF0sIFsidDA1OTIiLCAxLjBdLCBbInQwNTkzIiwgMS4wXSwgWyJ0MDU5NCIsIDEuMF0sIFsidDA1OTUiLCAxLjBdLCBbInQwNTk2IiwgMS4wXSwgWyJ0MDU5NyIsIDEuMF0sIFsidDA1OTgiLCAxLjBdLCBbInQwNTk5IiwgMS4wXSwgWyJ0MDYwMCIsIDEuMF0sIFsidDA2MDEiLCAxLjBdLCBbInQwNjAyIiwgMS4wXSwgWyJ0MDYwMyIsIDEuMF0sIFsidDA2MDQiLCAxLjBdLCBbInQwNjA1IiwgMS4wXSwgWyJ0MDYwNiIsIDEuMF0sIFsidDA2MDciLCAxLjBdLCBbInQwNjA4IiwgMS4wXSwgWyJ0MDYwOSIsIDEuMF0sIFsidDA2MTAiLCAxLjBdLCBbInQwNjExIiwgMS4wXSwgWyJ0MDYxMiIsIDEuMF0sIFsidDA2MTMiLCAxLjBdLCBbInQwNjE0IiwgMS4wXSwgWyJ0MDYxNSIsIDEuMF0sIFsidDA2MTYiLCAxLjBdLCBbInQwNjE3IiwgMS4wXSwgWyJ0MDYxOCIsIDEuMF0sIFsidDA2MTkiLCAxLjBdLCBbInQwNjIwIiwgMS4wXSwgWyJ0MDYyMSIsIDEuMF0sIFsidDA2MjIiLCAxLjBdLCBbInQwNjIzIiwgMS4wXSwgWyJ0MDYyNCIsIDEuMF0sIFsidDA2MjUiLCAxLjBdLCBbInQwNjI2IiwgMS4wXSwgWyJ0MDYyNyIsIDEuMF0sIFsidDA2MjgiLCAxLjBdLCBbInQwNjI5IiwgMS4wXSwgWyJ0MDYzMCIsIDEuMF0sIFsidDA2MzEiLCAxLjBdLCBbInQwNjMyIiwgMS4wXSwgWyJ0MDYzMyIsIDEuMF0sIFsidDA2MzQiLCAxLjBdLCBbInQwNjM1IiwgMS4wXSwgWyJ0MDYzNiIsIDEuMF0sIFsidDA2MzciLCAxLjBdLCBbInQwNjM4IiwgMS4wXSwgWyJ0MDYzOSIsIDEuMF0sIFsidDA2NDAiLCAxLjBdLCBbInQwNjQxIiwgMS4wXSwgWyJ0MDY0MiIsIDEuMF0sIFsidDA2NDMiLCAxLjBdLCBbInQwNjQ0IiwgMS4wXSwgWyJ0MDY0NSIsIDEuMF0sIFsidDA2NDYiLCAxLjBdLCBbInQwNjQ3IiwgMS4wXSwgWyJ0MDY0OCIsIDEuMF0sIFsidDA2NDkiLCAxLjBdLCBbInQwNjUwIiwgMS4wXSwgWyJ0MDY1MSIsIDEuMF0sIFsidDA2NTIiLCAxLjBdLCBbInQwNjUzIiwgMS4wXSwgWyJ0MDY1NCIsIDEuMF0sIFsidDA2NTUiLCAxLjBdLCBbInQwNjU2IiwgMS4wXSwgWyJ0MDY1NyIsIDEuMF0sIFsidDA2NTgiLCAxLjBdLCBbInQwNjU5IiwgMS4wXSwgWyJ0MDY2MCIsIDEuMF0sIFsidDA2NjEiLCAxLjBdLCBbInQwNjYyIiwgMS4wXSwgWyJ0MDY2MyIsIDEuMF0sIFsidDA2NjQiLCAxLjBdLCBbInQwNjY1IiwgMS4wXSwgWyJ0MDY2NiIsIDEuMF0sIFsidDA2NjciLCAxLjBdLCBbInQwNjY4IiwgMS4wXSwgWyJ0MDY2OSIsIDEuMF0sIFsidDA2NzAiLCAxLjBdLCBbInQwNjcxIiwgMS4wXSwgWyJ0MDY3MiIsIDEuMF0sIFsidDA2NzMiLCAxLjBdLCBbInQwNjc0IiwgMS4wXSwgWyJ0MDY3NSIsIDEuMF0sIFsidDA2NzYiLCAxLjBdLCBbInQwNjc3IiwgMS4wXSwgWyJ0MDY3OCIsIDEuMF0sIFsidDA2NzkiLCAxLjBdLCBbInQwNjgwIiwgMS4wXSwgWyJ0MDY4MSIsIDEuMF0sIFsidDA2ODIiLCAxLjBdLCBbInQwNjgzIiwgMS4wXSwgWyJ0MDY4NCIsIDEuMF0sIFsidDA2ODUiLCAxLjBdLCBbInQwNjg2IiwgMS4wXSwgWyJ0MDY4NyIsIDEuMF0sIFsidDA2ODgiLCAxLjBdLCBbInQwNjg5IiwgMS4wXSwgWyJ0MDY5MCIsIDEuMF0sIFsidDA2OTEiLCAxLjBdLCBbInQwNjkyIiwgMS4wXSwgWyJ0MDY5MyIsIDEuMF0sIFsidDA2OTQiLCAxLjBdLCBbInQwNjk1IiwgMS4wXSwgWyJ0MDY5NiIsIDEuMF0sIFsidDA2OTciLCAxLjBdLCBbInQwNjk4IiwgMS4wXSwgWyJ0MDY5OSIsIDEuMF0sIFsidDA3MDAiLCAxLjBdLCBbInQwNzAxIiwgMS4wXSwgWyJ0MDcwMiIsIDEuMF0sIFsidDA3MDMiLCAxLjBdLCBbInQwNzA0IiwgMS4wXSwgWyJ0MDcwNSIsIDEuMF0sIFsidDA3MDYiLCAxLjBdLCBbInQwNzA3IiwgMS4wXSwgWyJ0MDcwOCIsIDEuMF0sIFsidDA3MDkiLCAxLjBdLCBbInQwNzEwIiwgMS4wXSwgWyJ0MDcxMSIsIDEuMF0sIFsidDA3MTIiLCAxLjBdLCBbInQwNzEzIiwgMS4wXSwgWyJ0MDcxNCIsIDEuMF0sIFsidDA3MTUiLCAxLjBdLCBbInQwNzE2IiwgMS4wXSwgWyJ0MDcxNyIsIDEuMF0sIFsidDA3MTgiLCAxLjBdLCBbInQwNzE5IiwgMS4wXSwgWyJ0MDcyMCIsIDEuMF0sIFsidDA3MjEiLCAxLjBdLCBbInQwNzIyIiwgMS4wXSwgWyJ0MDcyMyIsIDEuMF0sIFsidDA3MjQiLCAxLjBdLCBbInQwNzI1IiwgMS4wXSwgWyJ0MDcyNiIsIDEuMF0sIFsidDA3MjciLCAxLjBdLCBbInQwNzI4IiwgMS4wXSwgWyJ0MDcyOSIsIDEuMF0sIFsidDA3MzAiLCAxLjBdLCBbInQwNzMxIiwgMS4wXSwgWyJ0MDczMiIsIDEuMF0sIFsidDA3MzMiLCAxLjBdLCBbInQwNzM0IiwgMS4wXSwgWyJ0MDczNSIsIDEuMF0sIFsidDA3MzYiLCAxLjBdLCBbInQwNzM3IiwgMS4wXSwgWyJ0MDczOCIsIDEuMF0sIFsidDA3MzkiLCAxLjBdLCBbInQwNzQwIiwgMS4wXSwgWyJ0MDc0MSIsIDEuMF0sIFsidDA3NDIiLCAxLjBdLCBbInQwNzQzIiwgMS4wXSwgWyJ0MDc0NCIsIDEuMF0sIFsidDA3NDUiLCAxLjBdLCBbInQwNzQ2IiwgMS4wXSwgWyJ0MDc0NyIsIDEuMF0sIFsidDA3NDgiLCAxLjBdLCBbInQwNzQ5IiwgMS4wXSwgWyJ0MDc1MCIsIDEuMF0sIFsidDA3NTEiLCAxLjBdLCBbInQwNzUyIiwgMS4wXSwgWyJ0MDc1MyIsIDEuMF0sIFsidDA3NTQiLCAxLjBdLCBbInQwNzU1IiwgMS4wXSwgWyJ0MDc1NiIsIDEuMF0sIFsidDA3NTciLCAxLjBdLCBbInQwNzU4IiwgMS4wXSwgWyJ0MDc1OSIsIDEuMF0sIFsidDA3NjAiLCAxLjBdLCBbInQwNzYxIiwgMS4wXSwgWyJ0MDc2MiIsIDEuMF0sIFsidDA3NjMiLCAxLjBdLCBbInQwNzY0IiwgMS4wXSwgWyJ0MDc2NSIsIDEuMF0sIFsidDA3NjYiLCAxLjBdLCBbInQwNzY3IiwgMS4wXSwgWyJ0MDc2OCIsIDEuMF0sIFsidDA3NjkiLCAxLjBdLCBbInQwNzcwIiwgMS4wXSwgWyJ0MDc3MSIsIDEuMF0sIFsidDA3NzIiLCAxLjBdLCBbInQwNzczIiwgMS4wXSwgWyJ0MDc3NCIsIDEuMF0sIFsidDA3NzUiLCAxLjBdLCBbInQwNzc2IiwgMS4wXSwgWyJ0MDc3NyIsIDEuMF0sIFsidDA3NzgiLCAxLjBdLCBbInQwNzc5IiwgMS4wXSwgWyJ0MDc4MCIsIDEuMF0sIFsidDA3ODEiLCAxLjBdLCBbInQwNzgyIiwgMS4wXSwgWyJ0MDc4MyIsIDEuMF0sIFsidDA3ODQiLCAxLjBdLCBbInQwNzg1IiwgMS4wXSwgWyJ0MDc4NiIsIDEuMF0sIFsidDA3ODciLCAxLjBdLCBbInQwNzg4IiwgMS4wXSwgWyJ0MDc4OSIsIDEuMF0sIFsidDA3OTAiLCAxLjBdLCBbInQwNzkxIiwgMS4wXSwgWyJ0MDc5MiIsIDEuMF0sIFsidDA3OTMiLCAxLjBdLCBbInQwNzk0IiwgMS4wXSwgWyJ0MDc5NSIsIDEuMF0sIFsidDA3OTYiLCAxLjBdLCBbInQwNzk3IiwgMS4wXSwgWyJ0MDc5OCIsIDEuMF0sIFsidDA3OTkiLCAxLjBdLCBbInQwODAwIiwgMS4wXSwgWyJ0MDgwMSIsIDEuMF0sIFsidDA4MDIiLCAxLjBdLCBbInQwODAzIiwgMS4wXSwgWyJ0MDgwNCIsIDEuMF0sIFsidDA4MDUiLCAxLjBdLCBbInQwODA2IiwgMS4wXSwgWyJ0MDgwNyIsIDEuMF0sIFsidDA4MDgiLCAxLjBdLCBbInQwODA5IiwgMS4wXSwgWyJ0MDgxMCIsIDEuMF0sIFsidDA4MTEiLCAxLjBdLCBbInQwODEyIiwgMS4wXSwgWyJ0MDgxMyIsIDEuMF0sIFsidDA4MTQiLCAxLjBdLCBbInQwODE1IiwgMS4wXSwgWyJ0MDgxNiIsIDEuMF0sIFsidDA4MTciLCAxLjBdLCBbInQwODE4IiwgMS4wXSwgWyJ0MDgxOSIsIDEuMF0sIFsidDA4MjAiLCAxLjBdLCBbInQwODIxIiwgMS4wXSwgWyJ0MDgyMiIsIDEuMF0sIFsidDA4MjMiLCAxLjBdLCBbInQwODI0IiwgMS4wXSwgWyJ0MDgyNSIsIDEuMF0sIFsidDA4MjYiLCAxLjBdLCBbInQwODI3IiwgMS4wXSwgWyJ0MDgyOCIsIDEuMF0sIFsidDA4MjkiLCAxLjBdLCBbInQwODMwIiwgMS4wXSwgWyJ0MDgzMSIsIDEuMF0sIFsidDA4MzIiLCAxLjBdLCBbInQwODMzIiwgMS4wXSwgWyJ0MDgzNCIsIDEuMF0sIFsidDA4MzUiLCAxLjBdLCBbInQwODM2IiwgMS4wXSwgWyJ0MDgzNyIsIDEuMF0sIFsidDA4MzgiLCAxLjBdLCBbInQwODM5IiwgMS4wXSwgWyJ0MDg0MCIsIDEuMF0sIFsidDA4NDEiLCAxLjBdLCBbInQwODQyIiwgMS4wXSwgWyJ0MDg0MyIsIDEuMF0sIFsidDA4NDQiLCAxLjBdLCBbInQwODQ1IiwgMS4wXSwgWyJ0MDg0NiIsIDEuMF0sIFsidDA4NDciLCAxLjBdLCBbInQwODQ4IiwgMS4wXSwgWyJ0MDg0OSIsIDEuMF0sIFsidDA4NTAiLCAxLjBdLCBbInQwODUxIiwgMS4wXSwgWyJ0MDg1MiIsIDEuMF0sIFsidDA4NTMiLCAxLjBdLCBbInQwODU0IiwgMS4wXSwgWyJ0MDg1NSIsIDEuMF0sIFsidDA4NTYiLCAxLjBdLCBbInQwODU3IiwgMS4wXSwgWyJ0MDg1OCIsIDEuMF0sIFsidDA4NTkiLCAxLjBdLCBbInQwODYwIiwgMS4wXSwgWyJ0MDg2MSIsIDEuMF0sIFsidDA4NjIiLCAxLjBdLCBbInQwODYzIiwgMS4wXSwgWyJ0MDg2NCIsIDEuMF0sIFsidDA4NjUiLCAxLjBdLCBbInQwODY2IiwgMS4wXSwgWyJ0MDg2NyIsIDEuMF0sIFsidDA4NjgiLCAxLjBdLCBbInQwODY5IiwgMS4wXSwgWyJ0MDg3MCIsIDEuMF0sIFsidDA4NzEiLCAxLjBdLCBbInQwODcyIiwgMS4wXSwgWyJ0MDg3MyIsIDEuMF0sIFsidDA4NzQiLCAxLjBdLCBbInQwODc1IiwgMS4wXSwgWyJ0MDg3NiIsIDEuMF0sIFsidDA4NzciLCAxLjBdLCBbInQwODc4IiwgMS4wXSwgWyJ0MDg3OSIsIDEuMF0sIFsidDA4ODAiLCAxLjBdLCBbInQwODgxIiwgMS4wXSwgWyJ0MDg4MiIsIDEuMF0sIFsidDA4ODMiLCAxLjBdLCBbInQwODg0IiwgMS4wXSwgWyJ0MDg4NSIsIDEuMF0sIFsidDA4ODYiLCAxLjBdLCBbInQwODg3IiwgMS4wXSwgWyJ0MDg4OCIsIDEuMF0sIFsidDA4ODkiLCAxLjBdLCBbInQwODkwIiwgMS4wXSwgWyJ0MDg5MSIsIDEuMF0sIFsidDA4OTIiLCAxLjBdLCBbInQwODkzIiwgMS4wXSwgWyJ0MDg5NCIsIDEuMF0sIFsidDA4OTUiLCAxLjBdLCBbInQwODk2IiwgMS4wXSwgWyJ0MDg5NyIsIDEuMF0sIFsidDA4OTgiLCAxLjBdLCBbInQwODk5IiwgMS4wXSwgWyJ0MDkwMCIsIDEuMF0sIFsidDA5MDEiLCAxLjBdLCBbInQwOTAyIiwgMS4wXSwgWyJ0MDkwMyIsIDEuMF0sIFsidDA5MDQiLCAxLjBdLCBbInQwOTA1IiwgMS4wXSwgWyJ0MDkwNiIsIDEuMF0sIFsidDA5MDciLCAxLjBdLCBbInQwOTA4IiwgMS4wXSwgWyJ0MDkwOSIsIDEuMF0sIFsidDA5MTAiLCAxLjBdLCBbInQwOTExIiwgMS4wXSwgWyJ0MDkxMiIsIDEuMF0sIFsidDA5MTMiLCAxLjBdLCBbInQwOTE0IiwgMS4wXSwgWyJ0MDkxNSIsIDEuMF0sIFsidDA5MTYiLCAxLjBdLCBbInQwOTE3IiwgMS4wXSwgWyJ0MDkxOCIsIDEuMF0sIFsidDA5MTkiLCAxLjBdLCBbInQwOTIwIiwgMS4wXSwgWyJ0MDkyMSIsIDEuMF0sIFsidDA5MjIiLCAxLjBdLCBbInQwOTIzIiwgMS4wXSwgWyJ0MDkyNCIsIDEuMF0sIFsidDA5MjUiLCAxLjBdLCBbInQwOTI2IiwgMS4wXSwgWyJ0MDkyNyIsIDEuMF0sIFsidDA5MjgiLCAxLjBdLCBbInQwOTI5IiwgMS4wXSwgWyJ0MDkzMCIsIDEuMF0sIFsidDA5MzEiLCAxLjBdLCBbInQwOTMyIiwgMS4wXSwgWyJ0MDkzMyIsIDEuMF0sIFsidDA5MzQiLCAxLjBdLCBbInQwOTM1IiwgMS4wXSwgWyJ0MDkzNiIsIDEuMF0sIFsidDA5MzciLCAxLjBdLCBbInQwOTM4IiwgMS4wXSwgWyJ0MDkzOSIsIDEuMF0sIFsidDA5NDAiLCAxLjBdLCBbInQwOTQxIiwgMS4wXSwgWyJ0MDk0MiIsIDEuMF0sIFsidDA5NDMiLCAxLjBdLCBbInQwOTQ0IiwgMS4wXSwgWyJ0MDk0NSIsIDEuMF0sIFsidDA5NDYiLCAxLjBdLCBbInQwOTQ3IiwgMS4wXSwgWyJ0MDk0OCIsIDEuMF0sIFsidDA5NDkiLCAxLjBdLCBbInQwOTUwIiwgMS4wXSwgWyJ0MDk1MSIsIDEuMF0sIFsidDA5NTIiLCAxLjBdLCBbInQwOTUzIiwgMS4wXSwgWyJ0MDk1NCIsIDEuMF0sIFsidDA5NTUiLCAxLjBdLCBbInQwOTU2IiwgMS4wXSwgWyJ0MDk1NyIsIDEuMF0sIFsidDA5NTgiLCAxLjBdLCBbInQwOTU5IiwgMS4wXSwgWyJ0MDk2MCIsIDEuMF0sIFsidDA5NjEiLCAxLjBdLCBbInQwOTYyIiwgMS4wXSwgWyJ0MDk2MyIsIDEuMF0sIFsidDA5NjQiLCAxLjBdLCBbInQwOTY1IiwgMS4wXSwgWyJ0MDk2NiIsIDEuMF0sIFsidDA5NjciLCAxLjBdLCBbInQwOTY4IiwgMS4wXSwgWyJ0MDk2OSIsIDEuMF0sIFsidDA5NzAiLCAxLjBdLCBbInQwOTcxIiwgMS4wXSwgWyJ0MDk3MiIsIDEuMF0sIFsidDA5NzMiLCAxLjBdLCBbInQwOTc0IiwgMS4wXSwgWyJ0MDk3NSIsIDEuMF0sIFsidDA5NzYiLCAxLjBdLCBbInQwOTc3IiwgMS4wXSwgWyJ0MDk3OCIsIDEuMF0sIFsidDA5NzkiLCAxLjBdLCBbInQwOTgwIiwgMS4wXSwgWyJ0MDk4MSIsIDEuMF0sIFsidDA5ODIiLCAxLjBdLCBbInQwOTgzIiwgMS4wXSwgWyJ0MDk4NCIsIDEuMF0sIFsidDA5ODUiLCAxLjBdLCBbInQwOTg2IiwgMS4wXSwgWyJ0MDk4NyIsIDEuMF0sIFsidDA5ODgiLCAxLjBdLCBbInQwOTg5IiwgMS4wXSwgWyJ0MDk5MCIsIDEuMF0sIFsidDA5OTEiLCAxLjBdLCBbInQwOTkyIiwgMS4wXSwgWyJ0MDk5MyIsIDEuMF0sIFsidDA5OTQiLCAxLjBdLCBbInQwOTk1IiwgMS4wXSwgWyJ0MDk5NiIsIDEuMF0sIFsidDA5OTciLCAxLjBdLCBbInQwOTk4IiwgMS4wXSwgWyJ0MDk5OSIsIDEuMF0sIFsidDEwMDAiLCAxLjBdLCBbInQxMDAxIiwgMS4wXSwgWyJ0MTAwMiIsIDEuMF0sIFsidDEwMDMiLCAxLjBdLCBbInQxMDA0IiwgMS4wXSwgWyJ0MTAwNSIsIDEuMF0sIFsidDEwMDYiLCAxLjBdLCBbInQxMDA3IiwgMS4wXSwgWyJ0MTAwOCIsIDEuMF0sIFsidDEwMDkiLCAxLjBdLCBbInQxMDEwIiwgMS4wXSwgWyJ0MTAxMSIsIDEuMF0sIFsidDEwMTIiLCAxLjBdLCBbInQxMDEzIiwgMS4wXSwgWyJ0MTAxNCIsIDEuMF0sIFsidDEwMTUiLCAxLjBdLCBbInQxMDE2IiwgMS4wXSwgWyJ0MTAxNyIsIDEuMF0sIFsidDEwMTgiLCAxLjBdLCBbInQxMDE5IiwgMS4wXSwgWyJ0MTAyMCIsIDEuMF0sIFsidDEwMjEiLCAxLjBdLCBbInQxMDIyIiwgMS4wXSwgWyJ0MTAyMyIsIDEuMF0sIFsidDEwMjQiLCAxLjBdLCBbInQxMDI1IiwgMS4wXSwgWyJ0MTAyNiIsIDEuMF0sIFsidDEwMjciLCAxLjBdLCBbInQxMDI4IiwgMS4wXSwgWyJ0MTAyOSIsIDEuMF0sIFsidDEwMzAiLCAxLjBdLCBbInQxMDMxIiwgMS4wXSwgWyJ0MTAzMiIsIDEuMF0sIFsidDEwMzMiLCAxLjBdLCBbInQxMDM0IiwgMS4wXSwgWyJ0MTAzNSIsIDEuMF0sIFsidDEwMzYiLCAxLjBdLCBbInQxMDM3IiwgMS4wXSwgWyJ0MTAzOCIsIDEuMF0sIFsidDEwMzkiLCAxLjBdLCBbInQxMDQwIiwgMS4wXSwgWyJ0MTA0MSIsIDEuMF0sIFsidDEwNDIiLCAxLjBdLCBbInQxMDQzIiwgMS4wXSwgWyJ0MTA0NCIsIDEuMF0sIFsidDEwNDUiLCAxLjBdLCBbInQxMDQ2IiwgMS4wXSwgWyJ0MTA0NyIsIDEuMF0sIFsidDEwNDgiLCAxLjBdLCBbInQxMDQ5IiwgMS4wXSwgWyJ0MTA1MCIsIDEuMF0sIFsidDEwNTEiLCAxLjBdLCBbInQxMDUyIiwgMS4wXSwgWyJ0MTA1MyIsIDEuMF0sIFsidDEwNTQiLCAxLjBdLCBbInQxMDU1IiwgMS4wXSwgWyJ0MTA1NiIsIDEuMF0sIFsidDEwNTciLCAxLjBdLCBbInQxMDU4IiwgMS4wXSwgWyJ0MTA1OSIsIDEuMF0sIFsidDEwNjAiLCAxLjBdLCBbInQxMDYxIiwgMS4wXSwgWyJ0MTA2MiIsIDEuMF0sIFsidDEwNjMiLCAxLjBdLCBbInQxMDY0IiwgMS4wXSwgWyJ0MTA2NSIsIDEuMF0sIFsidDEwNjYiLCAxLjBdLCBbInQxMDY3IiwgMS4wXSwgWyJ0MTA2OCIsIDEuMF0sIFsidDEwNjkiLCAxLjBdLCBbInQxMDcwIiwgMS4wXSwgWyJ0MTA3MSIsIDEuMF0sIFsidDEwNzIiLCAxLjBdLCBbInQxMDczIiwgMS4wXSwgWyJ0MTA3NCIsIDEuMF0sIFsidDEwNzUiLCAxLjBdLCBbInQxMDc2IiwgMS4wXSwgWyJ0MTA3NyIsIDEuMF0sIFsidDEwNzgiLCAxLjBdLCBbInQxMDc5IiwgMS4wXSwgWyJ0MTA4MCIsIDEuMF0sIFsidDEwODEiLCAxLjBdLCBbInQxMDgyIiwgMS4wXSwgWyJ0MTA4MyIsIDEuMF0sIFsidDEwODQiLCAxLjBdLCBbInQxMDg1IiwgMS4wXSwgWyJ0MTA4NiIsIDEuMF0sIFsidDEwODciLCAxLjBdLCBbInQxMDg4IiwgMS4wXSwgWyJ0MTA4OSIsIDEuMF0sIFsidDEwOTAiLCAxLjBdLCBbInQxMDkxIiwgMS4wXSwgWyJ0MTA5MiIsIDEuMF0sIFsidDEwOTMiLCAxLjBdLCBbInQxMDk0IiwgMS4wXSwgWyJ0MTA5NSIsIDEuMF0sIFsidDEwOTYiLCAxLjBdLCBbInQxMDk3IiwgMS4wXSwgWyJ0MTA5OCIsIDEuMF0sIFsidDEwOTkiLCAxLjBdLCBbInQxMTAwIiwgMS4wXSwgWyJ0MTEwMSIsIDEuMF0sIFsidDExMDIiLCAxLjBdLCBbInQxMTAzIiwgMS4wXSwgWyJ0MTEwNCIsIDEuMF0sIFsidDExMDUiLCAxLjBdLCBbInQxMTA2IiwgMS4wXSwgWyJ0MTEwNyIsIDEuMF0sIFsidDExMDgiLCAxLjBdLCBbInQxMTA5IiwgMS4wXSwgWyJ0MTExMCIsIDEuMF0sIFsidDExMTEiLCAxLjBdLCBbInQxMTEyIiwgMS4wXSwgWyJ0MTExMyIsIDEuMF0sIFsidDExMTQiLCAxLjBdLCBbInQxMTE1IiwgMS4wXSwgWyJ0MTExNiIsIDEuMF0sIFsidDExMTciLCAxLjBdLCBbInQxMTE4IiwgMS4wXSwgWyJ0MTExOSIsIDEuMF0sIFsidDExMjAiLCAxLjBdLCBbInQxMTIxIiwgMS4wXSwgWyJ0MTEyMiIsIDEuMF0sIFsidDExMjMiLCAxLjBdLCBbInQxMTI0IiwgMS4wXSwgWyJ0MTEyNSIsIDEuMF0sIFsidDExMjYiLCAxLjBdLCBbInQxMTI3IiwgMS4wXSwgWyJ0MTEyOCIsIDEuMF0sIFsidDExMjkiLCAxLjBdLCBbInQxMTMwIiwgMS4wXSwgWyJ0MTEzMSIsIDEuMF0sIFsidDExMzIiLCAxLjBdLCBbInQxMTMzIiwgMS4wXSwgWyJ0MTEzNCIsIDEuMF0sIFsidDExMzUiLCAxLjBdLCBbInQxMTM2IiwgMS4wXSwgWyJ0MTEzNyIsIDEuMF0sIFsidDExMzgiLCAxLjBdLCBbInQxMTM5IiwgMS4wXSwgWyJ0MTE0MCIsIDEuMF0sIFsidDExNDEiLCAxLjBdLCBbInQxMTQyIiwgMS4wXSwgWyJ0MTE0MyIsIDEuMF0sIFsidDExNDQiLCAxLjBdLCBbInQxMTQ1IiwgMS4wXSwgWyJ0MTE0NiIsIDEuMF0sIFsidDExNDciLCAxLjBdLCBbInQxMTQ4IiwgMS4wXSwgWyJ0MTE0OSIsIDEuMF0sIFsidDExNTAiLCAxLjBdLCBbInQxMTUxIiwgMS4wXSwgWyJ0MTE1MiIsIDEuMF0sIFsidDExNTMiLCAxLjBdLCBbInQxMTU0IiwgMS4wXSwgWyJ0MTE1NSIsIDEuMF0sIFsidDExNTYiLCAxLjBdLCBbInQxMTU3IiwgMS4wXSwgWyJ0MTE1OCIsIDEuMF0sIFsidDExNTkiLCAxLjBdLCBbInQxMTYwIiwgMS4wXSwgWyJ0MTE2MSIsIDEuMF0sIFsidDExNjIiLCAxLjBdLCBbInQxMTYzIiwgMS4wXSwgWyJ0MTE2NCIsIDEuMF0sIFsidDExNjUiLCAxLjBdLCBbInQxMTY2IiwgMS4wXSwgWyJ0MTE2NyIsIDEuMF0sIFsidDExNjgiLCAxLjBdLCBbInQxMTY5IiwgMS4wXSwgWyJ0MTE3MCIsIDEuMF0sIFsidDExNzEiLCAxLjBdLCBbInQxMTcyIiwgMS4wXSwgWyJ0MTE3MyIsIDEuMF0sIFsidDExNzQiLCAxLjBdLCBbInQxMTc1IiwgMS4wXSwgWyJ0MTE3NiIsIDEuMF0sIFsidDExNzciLCAxLjBdLCBbInQxMTc4IiwgMS4wXSwgWyJ0MTE3OSIsIDEuMF0sIFsidDExODAiLCAxLjBdLCBbInQxMTgxIiwgMS4wXSwgWyJ0MTE4MiIsIDEuMF0sIFsidDExODMiLCAxLjBdLCBbInQxMTg0IiwgMS4wXSwgWyJ0MTE4NSIsIDEuMF0sIFsidDExODYiLCAxLjBdLCBbInQxMTg3IiwgMS4wXSwgWyJ0MTE4OCIsIDEuMF0sIFsidDExODkiLCAxLjBdLCBbInQxMTkwIiwgMS4wXSwgWyJ0MTE5MSIsIDEuMF0sIFsidDExOTIiLCAxLjBdLCBbInQxMTkzIiwgMS4wXSwgWyJ0MTE5NCIsIDEuMF0sIFsidDExOTUiLCAxLjBdLCBbInQxMTk2IiwgMS4wXSwgWyJ0MTE5NyIsIDEuMF0sIFsidDExOTgiLCAxLjBdLCBbInQxMTk5IiwgMS4wXSwgWyJ0MTIwMCIsIDEuMF0sIFsidDEyMDEiLCAxLjBdLCBbInQxMjAyIiwgMS4wXSwgWyJ0MTIwMyIsIDEuMF0sIFsidDEyMDQiLCAxLjBdLCBbInQxMjA1IiwgMS4wXSwgWyJ0MTIwNiIsIDEuMF0sIFsidDEyMDciLCAxLjBdLCBbInQxMjA4IiwgMS4wXSwgWyJ0MTIwOSIsIDEuMF0sIFsidDEyMTAiLCAxLjBdLCBbInQxMjExIiwgMS4wXSwgWyJ0MTIxMiIsIDEuMF0sIFsidDEyMTMiLCAxLjBdLCBbInQxMjE0IiwgMS4wXSwgWyJ0MTIxNSIsIDEuMF0sIFsidDEyMTYiLCAxLjBdLCBbInQxMjE3IiwgMS4wXSwgWyJ0MTIxOCIsIDEuMF0sIFsidDEyMTkiLCAxLjBdLCBbInQxMjIwIiwgMS4wXSwgWyJ0MTIyMSIsIDEuMF0sIFsidDEyMjIiLCAxLjBdLCBbInQxMjIzIiwgMS4wXSwgWyJ0MTIyNCIsIDEuMF0sIFsidDEyMjUiLCAxLjBdLCBbInQxMjI2IiwgMS4wXSwgWyJ0MTIyNyIsIDEuMF0sIFsidDEyMjgiLCAxLjBdLCBbInQxMjI5IiwgMS4wXSwgWyJ0MTIzMCIsIDEuMF0sIFsidDEyMzEiLCAxLjBdLCBbInQxMjMyIiwgMS4wXSwgWyJ0MTIzMyIsIDEuMF0sIFsidDEyMzQiLCAxLjBdLCBbInQxMjM1IiwgMS4wXSwgWyJ0MTIzNiIsIDEuMF0sIFsidDEyMzciLCAxLjBdLCBbInQxMjM4IiwgMS4wXSwgWyJ0MTIzOSIsIDEuMF0sIFsidDEyNDAiLCAxLjBdLCBbInQxMjQxIiwgMS4wXSwgWyJ0MTI0MiIsIDEuMF0sIFsidDEyNDMiLCAxLjBdLCBbInQxMjQ0IiwgMS4wXSwgWyJ0MTI0NSIsIDEuMF0sIFsidDEyNDYiLCAxLjBdLCBbInQxMjQ3IiwgMS4wXSwgWyJ0MTI0OCIsIDEuMF0sIFsidDEyNDkiLCAxLjBdLCBbInQxMjUwIiwgMS4wXSwgWyJ0MTI1MSIsIDEuMF0sIFsidDEyNTIiLCAxLjBdLCBbInQxMjUzIiwgMS4wXSwgWyJ0MTI1NCIsIDEuMF0sIFsidDEyNTUiLCAxLjBdLCBbInQxMjU2IiwgMS4wXSwgWyJ0MTI1NyIsIDEuMF0sIFsidDEyNTgiLCAxLjBdLCBbInQxMjU5IiwgMS4wXSwgWyJ0MTI2MCIsIDEuMF0sIFsidDEyNjEiLCAxLjBdLCBbInQxMjYyIiwgMS4wXSwgWyJ0MTI2MyIsIDEuMF0sIFsidDEyNjQiLCAxLjBdLCBbInQxMjY1IiwgMS4wXSwgWyJ0MTI2NiIsIDEuMF0sIFsidDEyNjciLCAxLjBdLCBbInQxMjY4IiwgMS4wXSwgWyJ0MTI2OSIsIDEuMF0sIFsidDEyNzAiLCAxLjBdLCBbInQxMjcxIiwgMS4wXSwgWyJ0MTI3MiIsIDEuMF0sIFsidDEyNzMiLCAxLjBdLCBbInQxMjc0IiwgMS4wXSwgWyJ0MTI3NSIsIDEuMF0sIFsidDEyNzYiLCAxLjBdLCBbInQxMjc3IiwgMS4wXSwgWyJ0MTI3OCIsIDEuMF0sIFsidDEyNzkiLCAxLjBdLCBbInQxMjgwIiwgMS4wXSwgWyJ0MTI4MSIsIDEuMF0sIFsidDEyODIiLCAxLjBdLCBbInQxMjgzIiwgMS4wXSwgWyJ0MTI4NCIsIDEuMF0sIFsidDEyODUiLCAxLjBdLCBbInQxMjg2IiwgMS4wXSwgWyJ0MTI4NyIsIDEuMF0sIFsidDEyODgiLCAxLjBdLCBbInQxMjg5IiwgMS4wXSwgWyJ0MTI5MCIsIDEuMF0sIFsidDEyOTEiLCAxLjBdLCBbInQxMjkyIiwgMS4wXSwgWyJ0MTI5MyIsIDEuMF0sIFsidDEyOTQiLCAxLjBdLCBbInQxMjk1IiwgMS4wXSwgWyJ0MTI5NiIsIDEuMF0sIFsidDEyOTciLCAxLjBdLCBbInQxMjk4IiwgMS4wXSwgWyJ0MTI5OSIsIDEuMF0sIFsidDEzMDAiLCAxLjBdLCBbInQxMzAxIiwgMS4wXSwgWyJ0MTMwMiIsIDEuMF0sIFsidDEzMDMiLCAxLjBdLCBbInQxMzA0IiwgMS4wXSwgWyJ0MTMwNSIsIDEuMF0sIFsidDEzMDYiLCAxLjBdLCBbInQxMzA3IiwgMS4wXSwgWyJ0MTMwOCIsIDEuMF0sIFsidDEzMDkiLCAxLjBdLCBbInQxMzEwIiwgMS4wXSwgWyJ0MTMxMSIsIDEuMF0sIFsidDEzMTIiLCAxLjBdLCBbInQxMzEzIiwgMS4wXSwgWyJ0MTMxNCIsIDEuMF0sIFsidDEzMTUiLCAxLjBdLCBbInQxMzE2IiwgMS4wXSwgWyJ0MTMxNyIsIDEuMF0sIFsidDEzMTgiLCAxLjBdLCBbInQxMzE5IiwgMS4wXSwgWyJ0MTMyMCIsIDEuMF0sIFsidDEzMjEiLCAxLjBdLCBbInQxMzIyIiwgMS4wXSwgWyJ0MTMyMyIsIDEuMF0sIFsidDEzMjQiLCAxLjBdLCBbInQxMzI1IiwgMS4wXSwgWyJ0MTMyNiIsIDEuMF0sIFsidDEzMjciLCAxLjBdLCBbInQxMzI4IiwgMS4wXSwgWyJ0MTMyOSIsIDEuMF0sIFsidDEzMzAiLCAxLjBdLCBbInQxMzMxIiwgMS4wXSwgWyJ0MTMzMiIsIDEuMF0sIFsidDEzMzMiLCAxLjBdLCBbInQxMzM0IiwgMS4wXSwgWyJ0MTMzNSIsIDEuMF0sIFsidDEzMzYiLCAxLjBdLCBbInQxMzM3IiwgMS4wXSwgWyJ0MTMzOCIsIDEuMF0sIFsidDEzMzkiLCAxLjBdLCBbInQxMzQwIiwgMS4wXSwgWyJ0MTM0MSIsIDEuMF0sIFsidDEzNDIiLCAxLjBdLCBbInQxMzQzIiwgMS4wXSwgWyJ0MTM0NCIsIDEuMF0sIFsidDEzNDUiLCAxLjBdLCBbInQxMzQ2IiwgMS4wXSwgWyJ0MTM0NyIsIDEuMF0sIFsidDEzNDgiLCAxLjBdLCBbInQxMzQ5IiwgMS4wXSwgWyJ0MTM1MCIsIDEuMF0sIFsidDEzNTEiLCAxLjBdLCBbInQxMzUyIiwgMS4wXSwgWyJ0MTM1MyIsIDEuMF0sIFsidDEzNTQiLCAxLjBdLCBbInQxMzU1IiwgMS4wXSwgWyJ0MTM1NiIsIDEuMF0sIFsidDEzNTciLCAxLjBdLCBbInQxMzU4IiwgMS4wXSwgWyJ0MTM1OSIsIDEuMF0sIFsidDEzNjAiLCAxLjBdLCBbInQxMzYxIiwgMS4wXSwgWyJ0MTM2MiIsIDEuMF0sIFsidDEzNjMiLCAxLjBdLCBbInQxMzY0IiwgMS4wXSwgWyJ0MTM2NSIsIDEuMF0sIFsidDEzNjYiLCAxLjBdLCBbInQxMzY3IiwgMS4wXSwgWyJ0MTM2OCIsIDEuMF0sIFsidDEzNjkiLCAxLjBdLCBbInQxMzcwIiwgMS4wXSwgWyJ0MTM3MSIsIDEuMF0sIFsidDEzNzIiLCAxLjBdLCBbInQxMzczIiwgMS4wXSwgWyJ0MTM3NCIsIDEuMF0sIFsidDEzNzUiLCAxLjBdLCBbInQxMzc2IiwgMS4wXSwgWyJ0MTM3NyIsIDEuMF0sIFsidDEzNzgiLCAxLjBdLCBbInQxMzc5IiwgMS4wXSwgWyJ0MTM4MCIsIDEuMF0sIFsidDEzODEiLCAxLjBdLCBbInQxMzgyIiwgMS4wXSwgWyJ0MTM4MyIsIDEuMF0sIFsidDEzODQiLCAxLjBdLCBbInQxMzg1IiwgMS4wXSwgWyJ0MTM4NiIsIDEuMF0sIFsidDEzODciLCAxLjBdLCBbInQxMzg4IiwgMS4wXSwgWyJ0MTM4OSIsIDEuMF0sIFsidDEzOTAiLCAxLjBdLCBbInQxMzkxIiwgMS4wXSwgWyJ0MTM5MiIsIDEuMF0sIFsidDEzOTMiLCAxLjBdLCBbInQxMzk0IiwgMS4wXSwgWyJ0MTM5NSIsIDEuMF0sIFsidDEzOTYiLCAxLjBdLCBbInQxMzk3IiwgMS4wXSwgWyJ0MTM5OCIsIDEuMF0sIFsidDEzOTkiLCAxLjBdLCBbInQxNDAwIiwgMS4wXSwgWyJ0MTQwMSIsIDEuMF0sIFsidDE0MDIiLCAxLjBdLCBbInQxNDAzIiwgMS4wXSwgWyJ0MTQwNCIsIDEuMF0sIFsidDE0MDUiLCAxLjBdLCBbInQxNDA2IiwgMS4wXSwgWyJ0MTQwNyIsIDEuMF0sIFsidDE0MDgiLCAxLjBdLCBbInQxNDA5IiwgMS4wXSwgWyJ0MTQxMCIsIDEuMF0sIFsidDE0MTEiLCAxLjBdLCBbInQxNDEyIiwgMS4wXSwgWyJ0MTQxMyIsIDEuMF0sIFsidDE0MTQiLCAxLjBdLCBbInQxNDE1IiwgMS4wXSwgWyJ0MTQxNiIsIDEuMF0sIFsidDE0MTciLCAxLjBdLCBbInQxNDE4IiwgMS4wXSwgWyJ0MTQxOSIsIDEuMF0sIFsidDE0MjAiLCAxLjBdLCBbInQxNDIxIiwgMS4wXSwgWyJ0MTQyMiIsIDEuMF0sIFsidDE0MjMiLCAxLjBdLCBbInQxNDI0IiwgMS4wXSwgWyJ0MTQyNSIsIDEuMF0sIFsidDE0MjYiLCAxLjBdLCBbInQxNDI3IiwgMS4wXSwgWyJ0MTQyOCIsIDEuMF0sIFsidDE0MjkiLCAxLjBdLCBbInQxNDMwIiwgMS4wXSwgWyJ0MTQzMSIsIDEuMF0sIFsidDE0MzIiLCAxLjBdLCBbInQxNDMzIiwgMS4wXSwgWyJ0MTQzNCIsIDEuMF0sIFsidDE0MzUiLCAxLjBdLCBbInQxNDM2IiwgMS4wXSwgWyJ0MTQzNyIsIDEuMF0sIFsidDE0MzgiLCAxLjBdLCBbInQxNDM5IiwgMS4wXSwgWyJ0MTQ0MCIsIDEuMF0sIFsidDE0NDEiLCAxLjBdLCBbInQxNDQyIiwgMS4wXSwgWyJ0MTQ0MyIsIDEuMF0sIFsidDE0NDQiLCAxLjBdLCBbInQxNDQ1IiwgMS4wXSwgWyJ0MTQ0NiIsIDEuMF0sIFsidDE0NDciLCAxLjBdLCBbInQxNDQ4IiwgMS4wXSwgWyJ0MTQ0OSIsIDEuMF0sIFsidDE0NTAiLCAxLjBdLCBbInQxNDUxIiwgMS4wXSwgWyJ0MTQ1MiIsIDEuMF0sIFsidDE0NTMiLCAxLjBdLCBbInQxNDU0IiwgMS4wXSwgWyJ0MTQ1NSIsIDEuMF0sIFsidDE0NTYiLCAxLjBdLCBbInQxNDU3IiwgMS4wXSwgWyJ0MTQ1OCIsIDEuMF0sIFsidDE0NTkiLCAxLjBdLCBbInQxNDYwIiwgMS4wXSwgWyJ0MTQ2MSIsIDEuMF0sIFsidDE0NjIiLCAxLjBdLCBbInQxNDYzIiwgMS4wXSwgWyJ0MTQ2NCIsIDEuMF0sIFsidDE0NjUiLCAxLjBdLCBbInQxNDY2IiwgMS4wXSwgWyJ0MTQ2NyIsIDEuMF0sIFsidDE0NjgiLCAxLjBdLCBbInQxNDY5IiwgMS4wXSwgWyJ0MTQ3MCIsIDEuMF0sIFsidDE0NzEiLCAxLjBdLCBbInQxNDcyIiwgMS4wXSwgWyJ0MTQ3MyIsIDEuMF0sIFsidDE0NzQiLCAxLjBdLCBbInQxNDc1IiwgMS4wXSwgWyJ0MTQ3NiIsIDEuMF0sIFsidDE0NzciLCAxLjBdLCBbInQxNDc4IiwgMS4wXSwgWyJ0MTQ3OSIsIDEuMF0sIFsidDE0ODAiLCAxLjBdLCBbInQxNDgxIiwgMS4wXSwgWyJ0MTQ4MiIsIDEuMF0sIFsidDE0ODMiLCAxLjBdLCBbInQxNDg0IiwgMS4wXSwgWyJ0MTQ4NSIsIDEuMF0sIFsidDE0ODYiLCAxLjBdLCBbInQxNDg3IiwgMS4wXSwgWyJ0MTQ4OCIsIDEuMF0sIFsidDE0ODkiLCAxLjBdLCBbInQxNDkwIiwgMS4wXSwgWyJ0MTQ5MSIsIDEuMF0sIFsidDE0OTIiLCAxLjBdLCBbInQxNDkzIiwgMS4wXSwgWyJ0MTQ5NCIsIDEuMF0sIFsidDE0OTUiLCAxLjBdLCBbInQxNDk2IiwgMS4wXSwgWyJ0MTQ5NyIsIDEuMF0sIFsidDE0OTgiLCAxLjBdLCBbInQxNDk5IiwgMS4wXSwgWyJ0MTUwMCIsIDEuMF0sIFsidDE1MDEiLCAxLjBdLCBbInQxNTAyIiwgMS4wXSwgWyJ0MTUwMyIsIDEuMF0sIFsidDE1MDQiLCAxLjBdLCBbInQxNTA1IiwgMS4wXSwgWyJ0MTUwNiIsIDEuMF0sIFsidDE1MDciLCAxLjBdLCBbInQxNTA4IiwgMS4wXSwgWyJ0MTUwOSIsIDEuMF0sIFsidDE1MTAiLCAxLjBdLCBbInQxNTExIiwgMS4wXSwgWyJ0MTUxMiIsIDEuMF0sIFsidDE1MTMiLCAxLjBdLCBbInQxNTE0IiwgMS4wXSwgWyJ0MTUxNSIsIDEuMF0sIFsidDE1MTYiLCAxLjBdLCBbInQxNTE3IiwgMS4wXSwgWyJ0MTUxOCIsIDEuMF0sIFsidDE1MTkiLCAxLjBdLCBbInQxNTIwIiwgMS4wXSwgWyJ0MTUyMSIsIDEuMF0sIFsidDE1MjIiLCAxLjBdLCBbInQxNTIzIiwgMS4wXSwgWyJ0MTUyNCIsIDEuMF0sIFsidDE1MjUiLCAxLjBdLCBbInQxNTI2IiwgMS4wXSwgWyJ0MTUyNyIsIDEuMF0sIFsidDE1MjgiLCAxLjBdLCBbInQxNTI5IiwgMS4wXSwgWyJ0MTUzMCIsIDEuMF0sIFsidDE1MzEiLCAxLjBdLCBbInQxNTMyIiwgMS4wXSwgWyJ0MTUzMyIsIDEuMF0sIFsidDE1MzQiLCAxLjBdLCBbInQxNTM1IiwgMS4wXSwgWyJ0MTUzNiIsIDEuMF0sIFsidDE1MzciLCAxLjBdLCBbInQxNTM4IiwgMS4wXSwgWyJ0MTUzOSIsIDEuMF0sIFsidDE1NDAiLCAxLjBdLCBbInQxNTQxIiwgMS4wXSwgWyJ0MTU0MiIsIDEuMF0sIFsidDE1NDMiLCAxLjBdLCBbInQxNTQ0IiwgMS4wXSwgWyJ0MTU0NSIsIDEuMF0sIFsidDE1NDYiLCAxLjBdLCBbInQxNTQ3IiwgMS4wXSwgWyJ0MTU0OCIsIDEuMF0sIFsidDE1NDkiLCAxLjBdLCBbInQxNTUwIiwgMS4wXSwgWyJ0MTU1MSIsIDEuMF0sIFsidDE1NTIiLCAxLjBdLCBbInQxNTUzIiwgMS4wXSwgWyJ0MTU1NCIsIDEuMF0sIFsidDE1NTUiLCAxLjBdLCBbInQxNTU2IiwgMS4wXSwgWyJ0MTU1NyIsIDEuMF0sIFsidDE1NTgiLCAxLjBdLCBbInQxNTU5IiwgMS4wXSwgWyJ0MTU2MCIsIDEuMF0sIFsidDE1NjEiLCAxLjBdLCBbInQxNTYyIiwgMS4wXSwgWyJ0MTU2MyIsIDEuMF0sIFsidDE1NjQiLCAxLjBdLCBbInQxNTY1IiwgMS4wXSwgWyJ0MTU2NiIsIDEuMF0sIFsidDE1NjciLCAxLjBdLCBbInQxNTY4IiwgMS4wXSwgWyJ0MTU2OSIsIDEuMF0sIFsidDE1NzAiLCAxLjBdLCBbInQxNTcxIiwgMS4wXSwgWyJ0MTU3MiIsIDEuMF0sIFsidDE1NzMiLCAxLjBdLCBbInQxNTc0IiwgMS4wXSwgWyJ0MTU3NSIsIDEuMF0sIFsidDE1NzYiLCAxLjBdLCBbInQxNTc3IiwgMS4wXSwgWyJ0MTU3OCIsIDEuMF0sIFsidDE1NzkiLCAxLjBdLCBbInQxNTgwIiwgMS4wXSwgWyJ0MTU4MSIsIDEuMF0sIFsidDE1ODIiLCAxLjBdLCBbInQxNTgzIiwgMS4wXSwgWyJ0MTU4NCIsIDEuMF0sIFsidDE1ODUiLCAxLjBdLCBbInQxNTg2IiwgMS4wXSwgWyJ0MTU4NyIsIDEuMF0sIFsidDE1ODgiLCAxLjBdLCBbInQxNTg5IiwgMS4wXSwgWyJ0MTU5MCIsIDEuMF0sIFsidDE1OTEiLCAxLjBdLCBbInQxNTkyIiwgMS4wXSwgWyJ0MTU5MyIsIDEuMF0sIFsidDE1OTQiLCAxLjBdLCBbInQxNTk1IiwgMS4wXSwgWyJ0MTU5NiIsIDEuMF0sIFsidDE1OTciLCAxLjBdLCBbInQxNTk4IiwgMS4wXSwgWyJ0MTU5OSIsIDEuMF0sIFsidDE2MDAiLCAxLjBdLCBbInQxNjAxIiwgMS4wXSwgWyJ0MTYwMiIsIDEuMF0sIFsidDE2MDMiLCAxLjBdLCBbInQxNjA0IiwgMS4wXSwgWyJ0MTYwNSIsIDEuMF0sIFsidDE2MDYiLCAxLjBdLCBbInQxNjA3IiwgMS4wXSwgWyJ0MTYwOCIsIDEuMF0sIFsidDE2MDkiLCAxLjBdLCBbInQxNjEwIiwgMS4wXSwgWyJ0MTYxMSIsIDEuMF0sIFsidDE2MTIiLCAxLjBdLCBbInQxNjEzIiwgMS4wXSwgWyJ0MTYxNCIsIDEuMF0sIFsidDE2MTUiLCAxLjBdLCBbInQxNjE2IiwgMS4wXSwgWyJ0MTYxNyIsIDEuMF0sIFsidDE2MTgiLCAxLjBdLCBbInQxNjE5IiwgMS4wXSwgWyJ0MTYyMCIsIDEuMF0sIFsidDE2MjEiLCAxLjBdLCBbInQxNjIyIiwgMS4wXSwgWyJ0MTYyMyIsIDEuMF0sIFsidDE2MjQiLCAxLjBdLCBbInQxNjI1IiwgMS4wXSwgWyJ0MTYyNiIsIDEuMF0sIFsidDE2MjciLCAxLjBdLCBbInQxNjI4IiwgMS4wXSwgWyJ0MTYyOSIsIDEuMF0sIFsidDE2MzAiLCAxLjBdLCBbInQxNjMxIiwgMS4wXSwgWyJ0MTYzMiIsIDEuMF0sIFsidDE2MzMiLCAxLjBdLCBbInQxNjM0IiwgMS4wXSwgWyJ0MTYzNSIsIDEuMF0sIFsidDE2MzYiLCAxLjBdLCBbInQxNjM3IiwgMS4wXSwgWyJ0MTYzOCIsIDEuMF0sIFsidDE2MzkiLCAxLjBdLCBbInQxNjQwIiwgMS4wXSwgWyJ0MTY0MSIsIDEuMF0sIFsidDE2NDIiLCAxLjBdLCBbInQxNjQzIiwgMS4wXSwgWyJ0MTY0NCIsIDEuMF0sIFsidDE2NDUiLCAxLjBdLCBbInQxNjQ2IiwgMS4wXSwgWyJ0MTY0NyIsIDEuMF0sIFsidDE2NDgiLCAxLjBdLCBbInQxNjQ5IiwgMS4wXSwgWyJ0MTY1MCIsIDEuMF0sIFsidDE2NTEiLCAxLjBdLCBbInQxNjUyIiwgMS4wXSwgWyJ0MTY1MyIsIDEuMF0sIFsidDE2NTQiLCAxLjBdLCBbInQxNjU1IiwgMS4wXSwgWyJ0MTY1NiIsIDEuMF0sIFsidDE2NTciLCAxLjBdLCBbInQxNjU4IiwgMS4wXSwgWyJ0MTY1OSIsIDEuMF0sIFsidDE2NjAiLCAxLjBdLCBbInQxNjYxIiwgMS4wXSwgWyJ0MTY2MiIsIDEuMF0sIFsidDE2NjMiLCAxLjBdLCBbInQxNjY0IiwgMS4wXSwgWyJ0MTY2NSIsIDEuMF0sIFsidDE2NjYiLCAxLjBdLCBbInQxNjY3IiwgMS4wXSwgWyJ0MTY2OCIsIDEuMF0sIFsidDE2NjkiLCAxLjBdLCBbInQxNjcwIiwgMS4wXSwgWyJ0MTY3MSIsIDEuMF0sIFsidDE2NzIiLCAxLjBdLCBbInQxNjczIiwgMS4wXSwgWyJ0MTY3NCIsIDEuMF0sIFsidDE2NzUiLCAxLjBdLCBbInQxNjc2IiwgMS4wXSwgWyJ0MTY3NyIsIDEuMF0sIFsidDE2NzgiLCAxLjBdLCBbInQxNjc5IiwgMS4wXSwgWyJ0MTY4MCIsIDEuMF0sIFsidDE2ODEiLCAxLjBdLCBbInQxNjgyIiwgMS4wXSwgWyJ0MTY4MyIsIDEuMF0sIFsidDE2ODQiLCAxLjBdLCBbInQxNjg1IiwgMS4wXSwgWyJ0MTY4NiIsIDEuMF0sIFsidDE2ODciLCAxLjBdLCBbInQxNjg4IiwgMS4wXSwgWyJ0MTY4OSIsIDEuMF0sIFsidDE2OTAiLCAxLjBdLCBbInQxNjkxIiwgMS4wXSwgWyJ0MTY5MiIsIDEuMF0sIFsidDE2OTMiLCAxLjBdLCBbInQxNjk0IiwgMS4wXSwgWyJ0MTY5NSIsIDEuMF0sIFsidDE2OTYiLCAxLjBdLCBbInQxNjk3IiwgMS4wXSwgWyJ0MTY5OCIsIDEuMF0sIFsidDE2OTkiLCAxLjBdLCBbInQxNzAwIiwgMS4wXSwgWyJ0MTcwMSIsIDEuMF0sIFsidDE3MDIiLCAxLjBdLCBbInQxNzAzIiwgMS4wXSwgWyJ0MTcwNCIsIDEuMF0sIFsidDE3MDUiLCAxLjBdLCBbInQxNzA2IiwgMS4wXSwgWyJ0MTcwNyIsIDEuMF0sIFsidDE3MDgiLCAxLjBdLCBbInQxNzA5IiwgMS4wXSwgWyJ0MTcxMCIsIDEuMF0sIFsidDE3MTEiLCAxLjBdLCBbInQxNzEyIiwgMS4wXSwgWyJ0MTcxMyIsIDEuMF0sIFsidDE3MTQiLCAxLjBdLCBbInQxNzE1IiwgMS4wXSwgWyJ0MTcxNiIsIDEuMF0sIFsidDE3MTciLCAxLjBdLCBbInQxNzE4IiwgMS4wXSwgWyJ0MTcxOSIsIDEuMF0sIFsidDE3MjAiLCAxLjBdLCBbInQxNzIxIiwgMS4wXSwgWyJ0MTcyMiIsIDEuMF0sIFsidDE3MjMiLCAxLjBdLCBbInQxNzI0IiwgMS4wXSwgWyJ0MTcyNSIsIDEuMF0sIFsidDE3MjYiLCAxLjBdLCBbInQxNzI3IiwgMS4wXSwgWyJ0MTcyOCIsIDEuMF0sIFsidDE3MjkiLCAxLjBdLCBbInQxNzMwIiwgMS4wXSwgWyJ0MTczMSIsIDEuMF0sIFsidDE3MzIiLCAxLjBdLCBbInQxNzMzIiwgMS4wXSwgWyJ0MTczNCIsIDEuMF0sIFsidDE3MzUiLCAxLjBdLCBbInQxNzM2IiwgMS4wXSwgWyJ0MTczNyIsIDEuMF0sIFsidDE3MzgiLCAxLjBdLCBbInQxNzM5IiwgMS4wXSwgWyJ0MTc0MCIsIDEuMF0sIFsidDE3NDEiLCAxLjBdLCBbInQxNzQyIiwgMS4wXSwgWyJ0MTc0MyIsIDEuMF0sIFsidDE3NDQiLCAxLjBdLCBbInQxNzQ1IiwgMS4wXSwgWyJ0MTc0NiIsIDEuMF0sIFsidDE3NDciLCAxLjBdLCBbInQxNzQ4IiwgMS4wXSwgWyJ0MTc0OSIsIDEuMF0sIFsidDE3NTAiLCAxLjBdLCBbInQxNzUxIiwgMS4wXSwgWyJ0MTc1MiIsIDEuMF0sIFsidDE3NTMiLCAxLjBdLCBbInQxNzU0IiwgMS4wXSwgWyJ0MTc1NSIsIDEuMF0sIFsidDE3NTYiLCAxLjBdLCBbInQxNzU3IiwgMS4wXSwgWyJ0MTc1OCIsIDEuMF0sIFsidDE3NTkiLCAxLjBdLCBbInQxNzYwIiwgMS4wXSwgWyJ0MTc2MSIsIDEuMF0sIFsidDE3NjIiLCAxLjBdLCBbInQxNzYzIiwgMS4wXSwgWyJ0MTc2NCIsIDEuMF0sIFsidDE3NjUiLCAxLjBdLCBbInQxNzY2IiwgMS4wXSwgWyJ0MTc2NyIsIDEuMF0sIFsidDE3NjgiLCAxLjBdLCBbInQxNzY5IiwgMS4wXSwgWyJ0MTc3MCIsIDEuMF0sIFsidDE3NzEiLCAxLjBdLCBbInQxNzcyIiwgMS4wXSwgWyJ0MTc3MyIsIDEuMF0sIFsidDE3NzQiLCAxLjBdLCBbInQxNzc1IiwgMS4wXSwgWyJ0MTc3NiIsIDEuMF0sIFsidDE3NzciLCAxLjBdLCBbInQxNzc4IiwgMS4wXSwgWyJ0MTc3OSIsIDEuMF0sIFsidDE3ODAiLCAxLjBdLCBbInQxNzgxIiwgMS4wXSwgWyJ0MTc4MiIsIDEuMF0sIFsidDE3ODMiLCAxLjBdLCBbInQxNzg0IiwgMS4wXSwgWyJ0MTc4NSIsIDEuMF0sIFsidDE3ODYiLCAxLjBdLCBbInQxNzg3IiwgMS4wXSwgWyJ0MTc4OCIsIDEuMF0sIFsidDE3ODkiLCAxLjBdLCBbInQxNzkwIiwgMS4wXSwgWyJ0MTc5MSIsIDEuMF0sIFsidDE3OTIiLCAxLjBdLCBbInQxNzkzIiwgMS4wXSwgWyJ0MTc5NCIsIDEuMF0sIFsidDE3OTUiLCAxLjBdLCBbInQxNzk2IiwgMS4wXSwgWyJ0MTc5NyIsIDEuMF0sIFsidDE3OTgiLCAxLjBdLCBbInQxNzk5IiwgMS4wXSwgWyJ0MTgwMCIsIDEuMF0sIFsidDE4MDEiLCAxLjBdLCBbInQxODAyIiwgMS4wXSwgWyJ0MTgwMyIsIDEuMF0sIFsidDE4MDQiLCAxLjBdLCBbInQxODA1IiwgMS4wXSwgWyJ0MTgwNiIsIDEuMF0sIFsidDE4MDciLCAxLjBdLCBbInQxODA4IiwgMS4wXSwgWyJ0MTgwOSIsIDEuMF0sIFsidDE4MTAiLCAxLjBdLCBbInQxODExIiwgMS4wXSwgWyJ0MTgxMiIsIDEuMF0sIFsidDE4MTMiLCAxLjBdLCBbInQxODE0IiwgMS4wXSwgWyJ0MTgxNSIsIDEuMF0sIFsidDE4MTYiLCAxLjBdLCBbInQxODE3IiwgMS4wXSwgWyJ0MTgxOCIsIDEuMF0sIFsidDE4MTkiLCAxLjBdLCBbInQxODIwIiwgMS4wXSwgWyJ0MTgyMSIsIDEuMF0sIFsidDE4MjIiLCAxLjBdLCBbInQxODIzIiwgMS4wXSwgWyJ0MTgyNCIsIDEuMF0sIFsidDE4MjUiLCAxLjBdLCBbInQxODI2IiwgMS4wXSwgWyJ0MTgyNyIsIDEuMF0sIFsidDE4MjgiLCAxLjBdLCBbInQxODI5IiwgMS4wXSwgWyJ0MTgzMCIsIDEuMF0sIFsidDE4MzEiLCAxLjBdLCBbInQxODMyIiwgMS4wXSwgWyJ0MTgzMyIsIDEuMF0sIFsidDE4MzQiLCAxLjBdLCBbInQxODM1IiwgMS4wXSwgWyJ0MTgzNiIsIDEuMF0sIFsidDE4MzciLCAxLjBdLCBbInQxODM4IiwgMS4wXSwgWyJ0MTgzOSIsIDEuMF0sIFsidDE4NDAiLCAxLjBdLCBbInQxODQxIiwgMS4wXSwgWyJ0MTg0MiIsIDEuMF0sIFsidDE4NDMiLCAxLjBdLCBbInQxODQ0IiwgMS4wXSwgWyJ0MTg0NSIsIDEuMF0sIFsidDE4NDYiLCAxLjBdLCBbInQxODQ3IiwgMS4wXSwgWyJ0MTg0OCIsIDEuMF0sIFsidDE4NDkiLCAxLjBdLCBbInQxODUwIiwgMS4wXSwgWyJ0MTg1MSIsIDEuMF0sIFsidDE4NTIiLCAxLjBdLCBbInQxODUzIiwgMS4wXSwgWyJ0MTg1NCIsIDEuMF0sIFsidDE4NTUiLCAxLjBdLCBbInQxODU2IiwgMS4wXSwgWyJ0MTg1NyIsIDEuMF0sIFsidDE4NTgiLCAxLjBdLCBbInQxODU5IiwgMS4wXSwgWyJ0MTg2MCIsIDEuMF0sIFsidDE4NjEiLCAxLjBdLCBbInQxODYyIiwgMS4wXSwgWyJ0MTg2MyIsIDEuMF0sIFsidDE4NjQiLCAxLjBdLCBbInQxODY1IiwgMS4wXSwgWyJ0MTg2NiIsIDEuMF0sIFsidDE4NjciLCAxLjBdLCBbInQxODY4IiwgMS4wXSwgWyJ0MTg2OSIsIDEuMF0sIFsidDE4NzAiLCAxLjBdLCBbInQxODcxIiwgMS4wXSwgWyJ0MTg3MiIsIDEuMF0sIFsidDE4NzMiLCAxLjBdLCBbInQxODc0IiwgMS4wXSwgWyJ0MTg3NSIsIDEuMF0sIFsidDE4NzYiLCAxLjBdLCBbInQxODc3IiwgMS4wXSwgWyJ0MTg3OCIsIDEuMF0sIFsidDE4NzkiLCAxLjBdLCBbInQxODgwIiwgMS4wXSwgWyJ0MTg4MSIsIDEuMF0sIFsidDE4ODIiLCAxLjBdLCBbInQxODgzIiwgMS4wXSwgWyJ0MTg4NCIsIDEuMF0sIFsidDE4ODUiLCAxLjBdLCBbInQxODg2IiwgMS4wXSwgWyJ0MTg4NyIsIDEuMF0sIFsidDE4ODgiLCAxLjBdLCBbInQxODg5IiwgMS4wXSwgWyJ0MTg5MCIsIDEuMF0sIFsidDE4OTEiLCAxLjBdLCBbInQxODkyIiwgMS4wXSwgWyJ0MTg5MyIsIDEuMF0sIFsidDE4OTQiLCAxLjBdLCBbInQxODk1IiwgMS4wXSwgWyJ0MTg5NiIsIDEuMF0sIFsidDE4OTciLCAxLjBdLCBbInQxODk4IiwgMS4wXSwgWyJ0MTg5OSIsIDEuMF0sIFsidDE5MDAiLCAxLjBdLCBbInQxOTAxIiwgMS4wXSwgWyJ0MTkwMiIsIDEuMF0sIFsidDE5MDMiLCAxLjBdLCBbInQxOTA0IiwgMS4wXSwgWyJ0MTkwNSIsIDEuMF0sIFsidDE5MDYiLCAxLjBdLCBbInQxOTA3IiwgMS4wXSwgWyJ0MTkwOCIsIDEuMF0sIFsidDE5MDkiLCAxLjBdLCBbInQxOTEwIiwgMS4wXSwgWyJ0MTkxMSIsIDEuMF0sIFsidDE5MTIiLCAxLjBdLCBbInQxOTEzIiwgMS4wXSwgWyJ0MTkxNCIsIDEuMF0sIFsidDE5MTUiLCAxLjBdLCBbInQxOTE2IiwgMS4wXSwgWyJ0MTkxNyIsIDEuMF0sIFsidDE5MTgiLCAxLjBdLCBbInQxOTE5IiwgMS4wXSwgWyJ0MTkyMCIsIDEuMF0sIFsidDE5MjEiLCAxLjBdLCBbInQxOTIyIiwgMS4wXSwgWyJ0MTkyMyIsIDEuMF0sIFsidDE5MjQiLCAxLjBdLCBbInQxOTI1IiwgMS4wXSwgWyJ0MTkyNiIsIDEuMF0sIFsidDE5MjciLCAxLjBdLCBbInQxOTI4IiwgMS4wXSwgWyJ0MTkyOSIsIDEuMF0sIFsidDE5MzAiLCAxLjBdLCBbInQxOTMxIiwgMS4wXSwgWyJ0MTkzMiIsIDEuMF0sIFsidDE5MzMiLCAxLjBdLCBbInQxOTM0IiwgMS4wXSwgWyJ0MTkzNSIsIDEuMF0sIFsidDE5MzYiLCAxLjBdLCBbInQxOTM3IiwgMS4wXSwgWyJ0MTkzOCIsIDEuMF0sIFsidDE5MzkiLCAxLjBdLCBbInQxOTQwIiwgMS4wXSwgWyJ0MTk0MSIsIDEuMF0sIFsidDE5NDIiLCAxLjBdLCBbInQxOTQzIiwgMS4wXSwgWyJ0MTk0NCIsIDEuMF0sIFsidDE5NDUiLCAxLjBdLCBbInQxOTQ2IiwgMS4wXSwgWyJ0MTk0NyIsIDEuMF0sIFsidDE5NDgiLCAxLjBdLCBbInQxOTQ5IiwgMS4wXSwgWyJ0MTk1MCIsIDEuMF0sIFsidDE5NTEiLCAxLjBdLCBbInQxOTUyIiwgMS4wXSwgWyJ0MTk1MyIsIDEuMF0sIFsidDE5NTQiLCAxLjBdLCBbInQxOTU1IiwgMS4wXSwgWyJ0MTk1NiIsIDEuMF0sIFsidDE5NTciLCAxLjBdLCBbInQxOTU4IiwgMS4wXSwgWyJ0MTk1OSIsIDEuMF0sIFsidDE5NjAiLCAxLjBdLCBbInQxOTYxIiwgMS4wXSwgWyJ0MTk2MiIsIDEuMF0sIFsidDE5NjMiLCAxLjBdLCBbInQxOTY0IiwgMS4wXSwgWyJ0MTk2NSIsIDEuMF0sIFsidDE5NjYiLCAxLjBdLCBbInQxOTY3IiwgMS4wXSwgWyJ0MTk2OCIsIDEuMF0sIFsidDE5NjkiLCAxLjBdLCBbInQxOTcwIiwgMS4wXSwgWyJ0MTk3MSIsIDEuMF0sIFsidDE5NzIiLCAxLjBdLCBbInQxOTczIiwgMS4wXSwgWyJ0MTk3NCIsIDEuMF0sIFsidDE5NzUiLCAxLjBdLCBbInQxOTc2IiwgMS4wXSwgWyJ0MTk3NyIsIDEuMF0sIFsidDE5NzgiLCAxLjBdLCBbInQxOTc5IiwgMS4wXSwgWyJ0MTk4MCIsIDEuMF0sIFsidDE5ODEiLCAxLjBdLCBbInQxOTgyIiwgMS4wXSwgWyJ0MTk4MyIsIDEuMF0sIFsidDE5ODQiLCAxLjBdLCBbInQxOTg1IiwgMS4wXSwgWyJ0MTk4NiIsIDEuMF0sIFsidDE5ODciLCAxLjBdLCBbInQxOTg4IiwgMS4wXSwgWyJ0MTk4OSIsIDEuMF0sIFsidDE5OTAiLCAxLjBdLCBbInQxOTkxIiwgMS4wXSwgWyJ0MTk5MiIsIDEuMF0sIFsidDE5OTMiLCAxLjBdLCBbInQxOTk0IiwgMS4wXSwgWyJ0MTk5NSIsIDEuMF0sIFsidDE5OTYiLCAxLjBdLCBbInQxOTk3IiwgMS4wXSwgWyJ0MTk5OCIsIDEuMF0sIFsidDE5OTkiLCAxLjBdLCBbInQyMDAwIiwgMS4wXSwgWyJ0MjAwMSIsIDEuMF0sIFsidDIwMDIiLCAxLjBdLCBbInQyMDAzIiwgMS4wXSwgWyJ0MjAwNCIsIDEuMF0sIFsidDIwMDUiLCAxLjBdLCBbInQyMDA2IiwgMS4wXSwgWyJ0MjAwNyIsIDEuMF0sIFsidDIwMDgiLCAxLjBdLCBbInQyMDA5IiwgMS4wXSwgWyJ0MjAxMCIsIDEuMF0sIFsidDIwMTEiLCAxLjBdLCBbInQyMDEyIiwgMS4wXSwgWyJ0MjAxMyIsIDEuMF0sIFsidDIwMTQiLCAxLjBdLCBbInQyMDE1IiwgMS4wXSwgWyJ0MjAxNiIsIDEuMF0sIFsidDIwMTciLCAxLjBdLCBbInQyMDE4IiwgMS4wXSwgWyJ0MjAxOSIsIDEuMF0sIFsidDIwMjAiLCAxLjBdLCBbInQyMDIxIiwgMS4wXSwgWyJ0MjAyMiIsIDEuMF0sIFsidDIwMjMiLCAxLjBdLCBbInQyMDI0IiwgMS4wXSwgWyJ0MjAyNSIsIDEuMF0sIFsidDIwMjYiLCAxLjBdLCBbInQyMDI3IiwgMS4wXSwgWyJ0MjAyOCIsIDEuMF0sIFsidDIwMjkiLCAxLjBdLCBbInQyMDMwIiwgMS4wXSwgWyJ0MjAzMSIsIDEuMF0sIFsidDIwMzIiLCAxLjBdLCBbInQyMDMzIiwgMS4wXSwgWyJ0MjAzNCIsIDEuMF0sIFsidDIwMzUiLCAxLjBdLCBbInQyMDM2IiwgMS4wXSwgWyJ0MjAzNyIsIDEuMF0sIFsidDIwMzgiLCAxLjBdLCBbInQyMDM5IiwgMS4wXSwgWyJ0MjA0MCIsIDEuMF0sIFsidDIwNDEiLCAxLjBdLCBbInQyMDQyIiwgMS4wXSwgWyJ0MjA0MyIsIDEuMF0sIFsidDIwNDQiLCAxLjBdLCBbInQyMDQ1IiwgMS4wXSwgWyJ0MjA0NiIsIDEuMF0sIFsidDIwNDciLCAxLjBdLCBbInQyMDQ4IiwgMS4wXSwgWyJ0MjA0OSIsIDEuMF0sIFsidDIwNTAiLCAxLjBdLCBbInQyMDUxIiwgMS4wXSwgWyJ0MjA1MiIsIDEuMF0sIFsidDIwNTMiLCAxLjBdLCBbInQyMDU0IiwgMS4wXSwgWyJ0MjA1NSIsIDEuMF0sIFsidDIwNTYiLCAxLjBdLCBbInQyMDU3IiwgMS4wXSwgWyJ0MjA1OCIsIDEuMF0sIFsidDIwNTkiLCAxLjBdLCBbInQyMDYwIiwgMS4wXSwgWyJ0MjA2MSIsIDEuMF0sIFsidDIwNjIiLCAxLjBdLCBbInQyMDYzIiwgMS4wXSwgWyJ0MjA2NCIsIDEuMF0sIFsidDIwNjUiLCAxLjBdLCBbInQyMDY2IiwgMS4wXSwgWyJ0MjA2NyIsIDEuMF0sIFsidDIwNjgiLCAxLjBdLCBbInQyMDY5IiwgMS4wXSwgWyJ0MjA3MCIsIDEuMF0sIFsidDIwNzEiLCAxLjBdLCBbInQyMDcyIiwgMS4wXSwgWyJ0MjA3MyIsIDEuMF0sIFsidDIwNzQiLCAxLjBdLCBbInQyMDc1IiwgMS4wXSwgWyJ0MjA3NiIsIDEuMF0sIFsidDIwNzciLCAxLjBdLCBbInQyMDc4IiwgMS4wXSwgWyJ0MjA3OSIsIDEuMF0sIFsidDIwODAiLCAxLjBdLCBbInQyMDgxIiwgMS4wXSwgWyJ0MjA4MiIsIDEuMF0sIFsidDIwODMiLCAxLjBdLCBbInQyMDg0IiwgMS4wXSwgWyJ0MjA4NSIsIDEuMF0sIFsidDIwODYiLCAxLjBdLCBbInQyMDg3IiwgMS4wXSwgWyJ0MjA4OCIsIDEuMF0sIFsidDIwODkiLCAxLjBdLCBbInQyMDkwIiwgMS4wXSwgWyJ0MjA5MSIsIDEuMF0sIFsidDIwOTIiLCAxLjBdLCBbInQyMDkzIiwgMS4wXSwgWyJ0MjA5NCIsIDEuMF0sIFsidDIwOTUiLCAxLjBdLCBbInQyMDk2IiwgMS4wXSwgWyJ0MjA5NyIsIDEuMF0sIFsidDIwOTgiLCAxLjBdLCBbInQyMDk5IiwgMS4wXSwgWyJ0MjEwMCIsIDEuMF0sIFsidDIxMDEiLCAxLjBdLCBbInQyMTAyIiwgMS4wXSwgWyJ0MjEwMyIsIDEuMF0sIFsidDIxMDQiLCAxLjBdLCBbInQyMTA1IiwgMS4wXSwgWyJ0MjEwNiIsIDEuMF0sIFsidDIxMDciLCAxLjBdLCBbInQyMTA4IiwgMS4wXSwgWyJ0MjEwOSIsIDEuMF0sIFsidDIxMTAiLCAxLjBdLCBbInQyMTExIiwgMS4wXSwgWyJ0MjExMiIsIDEuMF0sIFsidDIxMTMiLCAxLjBdLCBbInQyMTE0IiwgMS4wXSwgWyJ0MjExNSIsIDEuMF0sIFsidDIxMTYiLCAxLjBdLCBbInQyMTE3IiwgMS4wXSwgWyJ0MjExOCIsIDEuMF0sIFsidDIxMTkiLCAxLjBdLCBbInQyMTIwIiwgMS4wXSwgWyJ0MjEyMSIsIDEuMF0sIFsidDIxMjIiLCAxLjBdLCBbInQyMTIzIiwgMS4wXSwgWyJ0MjEyNCIsIDEuMF0sIFsidDIxMjUiLCAxLjBdLCBbInQyMTI2IiwgMS4wXSwgWyJ0MjEyNyIsIDEuMF0sIFsidDIxMjgiLCAxLjBdLCBbInQyMTI5IiwgMS4wXSwgWyJ0MjEzMCIsIDEuMF0sIFsidDIxMzEiLCAxLjBdLCBbInQyMTMyIiwgMS4wXSwgWyJ0MjEzMyIsIDEuMF0sIFsidDIxMzQiLCAxLjBdLCBbInQyMTM1IiwgMS4wXSwgWyJ0MjEzNiIsIDEuMF0sIFsidDIxMzciLCAxLjBdLCBbInQyMTM4IiwgMS4wXSwgWyJ0MjEzOSIsIDEuMF0sIFsidDIxNDAiLCAxLjBdLCBbInQyMTQxIiwgMS4wXSwgWyJ0MjE0MiIsIDEuMF0sIFsidDIxNDMiLCAxLjBdLCBbInQyMTQ0IiwgMS4wXSwgWyJ0MjE0NSIsIDEuMF0sIFsidDIxNDYiLCAxLjBdLCBbInQyMTQ3IiwgMS4wXSwgWyJ0MjE0OCIsIDEuMF0sIFsidDIxNDkiLCAxLjBdLCBbInQyMTUwIiwgMS4wXSwgWyJ0MjE1MSIsIDEuMF0sIFsidDIxNTIiLCAxLjBdLCBbInQyMTUzIiwgMS4wXSwgWyJ0MjE1NCIsIDEuMF0sIFsidDIxNTUiLCAxLjBdLCBbInQyMTU2IiwgMS4wXSwgWyJ0MjE1NyIsIDEuMF0sIFsidDIxNTgiLCAxLjBdLCBbInQyMTU5IiwgMS4wXSwgWyJ0MjE2MCIsIDEuMF0sIFsidDIxNjEiLCAxLjBdLCBbInQyMTYyIiwgMS4wXSwgWyJ0MjE2MyIsIDEuMF0sIFsidDIxNjQiLCAxLjBdLCBbInQyMTY1IiwgMS4wXSwgWyJ0MjE2NiIsIDEuMF0sIFsidDIxNjciLCAxLjBdLCBbInQyMTY4IiwgMS4wXSwgWyJ0MjE2OSIsIDEuMF0sIFsidDIxNzAiLCAxLjBdLCBbInQyMTcxIiwgMS4wXSwgWyJ0MjE3MiIsIDEuMF0sIFsidDIxNzMiLCAxLjBdLCBbInQyMTc0IiwgMS4wXSwgWyJ0MjE3NSIsIDEuMF0sIFsidDIxNzYiLCAxLjBdLCBbInQyMTc3IiwgMS4wXSwgWyJ0MjE3OCIsIDEuMF0sIFsidDIxNzkiLCAxLjBdLCBbInQyMTgwIiwgMS4wXSwgWyJ0MjE4MSIsIDEuMF0sIFsidDIxODIiLCAxLjBdLCBbInQyMTgzIiwgMS4wXSwgWyJ0MjE4NCIsIDEuMF0sIFsidDIxODUiLCAxLjBdLCBbInQyMTg2IiwgMS4wXSwgWyJ0MjE4NyIsIDEuMF0sIFsidDIxODgiLCAxLjBdLCBbInQyMTg5IiwgMS4wXSwgWyJ0MjE5MCIsIDEuMF0sIFsidDIxOTEiLCAxLjBdLCBbInQyMTkyIiwgMS4wXSwgWyJ0MjE5MyIsIDEuMF0sIFsidDIxOTQiLCAxLjBdLCBbInQyMTk1IiwgMS4wXSwgWyJ0MjE5NiIsIDEuMF0sIFsidDIxOTciLCAxLjBdLCBbInQyMTk4IiwgMS4wXSwgWyJ0MjE5OSIsIDEuMF0sIFsidDIyMDAiLCAxLjBdLCBbInQyMjAxIiwgMS4wXSwgWyJ0MjIwMiIsIDEuMF0sIFsidDIyMDMiLCAxLjBdLCBbInQyMjA0IiwgMS4wXSwgWyJ0MjIwNSIsIDEuMF0sIFsidDIyMDYiLCAxLjBdLCBbInQyMjA3IiwgMS4wXSwgWyJ0MjIwOCIsIDEuMF0sIFsidDIyMDkiLCAxLjBdLCBbInQyMjEwIiwgMS4wXSwgWyJ0MjIxMSIsIDEuMF0sIFsidDIyMTIiLCAxLjBdLCBbInQyMjEzIiwgMS4wXSwgWyJ0MjIxNCIsIDEuMF0sIFsidDIyMTUiLCAxLjBdLCBbInQyMjE2IiwgMS4wXSwgWyJ0MjIxNyIsIDEuMF0sIFsidDIyMTgiLCAxLjBdLCBbInQyMjE5IiwgMS4wXSwgWyJ0MjIyMCIsIDEuMF0sIFsidDIyMjEiLCAxLjBdLCBbInQyMjIyIiwgMS4wXSwgWyJ0MjIyMyIsIDEuMF0sIFsidDIyMjQiLCAxLjBdLCBbInQyMjI1IiwgMS4wXSwgWyJ0MjIyNiIsIDEuMF0sIFsidDIyMjciLCAxLjBdLCBbInQyMjI4IiwgMS4wXSwgWyJ0MjIyOSIsIDEuMF0sIFsidDIyMzAiLCAxLjBdLCBbInQyMjMxIiwgMS4wXSwgWyJ0MjIzMiIsIDEuMF0sIFsidDIyMzMiLCAxLjBdLCBbInQyMjM0IiwgMS4wXSwgWyJ0MjIzNSIsIDEuMF0sIFsidDIyMzYiLCAxLjBdLCBbInQyMjM3IiwgMS4wXSwgWyJ0MjIzOCIsIDEuMF0sIFsidDIyMzkiLCAxLjBdLCBbInQyMjQwIiwgMS4wXSwgWyJ0MjI0MSIsIDEuMF0sIFsidDIyNDIiLCAxLjBdLCBbInQyMjQzIiwgMS4wXSwgWyJ0MjI0NCIsIDEuMF0sIFsidDIyNDUiLCAxLjBdLCBbInQyMjQ2IiwgMS4wXSwgWyJ0MjI0NyIsIDEuMF0sIFsidDIyNDgiLCAxLjBdLCBbInQyMjQ5IiwgMS4wXSwgWyJ0MjI1MCIsIDEuMF0sIFsidDIyNTEiLCAxLjBdLCBbInQyMjUyIiwgMS4wXSwgWyJ0MjI1MyIsIDEuMF0sIFsidDIyNTQiLCAxLjBdLCBbInQyMjU1IiwgMS4wXSwgWyJ0MjI1NiIsIDEuMF0sIFsidDIyNTciLCAxLjBdLCBbInQyMjU4IiwgMS4wXSwgWyJ0MjI1OSIsIDEuMF0sIFsidDIyNjAiLCAxLjBdLCBbInQyMjYxIiwgMS4wXSwgWyJ0MjI2MiIsIDEuMF0sIFsidDIyNjMiLCAxLjBdLCBbInQyMjY0IiwgMS4wXSwgWyJ0MjI2NSIsIDEuMF0sIFsidDIyNjYiLCAxLjBdLCBbInQyMjY3IiwgMS4wXSwgWyJ0MjI2OCIsIDEuMF0sIFsidDIyNjkiLCAxLjBdLCBbInQyMjcwIiwgMS4wXSwgWyJ0MjI3MSIsIDEuMF0sIFsidDIyNzIiLCAxLjBdLCBbInQyMjczIiwgMS4wXSwgWyJ0MjI3NCIsIDEuMF0sIFsidDIyNzUiLCAxLjBdLCBbInQyMjc2IiwgMS4wXSwgWyJ0MjI3NyIsIDEuMF0sIFsidDIyNzgiLCAxLjBdLCBbInQyMjc5IiwgMS4wXSwgWyJ0MjI4MCIsIDEuMF0sIFsidDIyODEiLCAxLjBdLCBbInQyMjgyIiwgMS4wXSwgWyJ0MjI4MyIsIDEuMF0sIFsidDIyODQiLCAxLjBdLCBbInQyMjg1IiwgMS4wXSwgWyJ0MjI4NiIsIDEuMF0sIFsidDIyODciLCAxLjBdLCBbInQyMjg4IiwgMS4wXSwgWyJ0MjI4OSIsIDEuMF0sIFsidDIyOTAiLCAxLjBdLCBbInQyMjkxIiwgMS4wXSwgWyJ0MjI5MiIsIDEuMF0sIFsidDIyOTMiLCAxLjBdLCBbInQyMjk0IiwgMS4wXSwgWyJ0MjI5NSIsIDEuMF0sIFsidDIyOTYiLCAxLjBdLCBbInQyMjk3IiwgMS4wXSwgWyJ0MjI5OCIsIDEuMF0sIFsidDIyOTkiLCAxLjBdLCBbInQyMzAwIiwgMS4wXSwgWyJ0MjMwMSIsIDEuMF0sIFsidDIzMDIiLCAxLjBdLCBbInQyMzAzIiwgMS4wXSwgWyJ0MjMwNCIsIDEuMF0sIFsidDIzMDUiLCAxLjBdLCBbInQyMzA2IiwgMS4wXSwgWyJ0MjMwNyIsIDEuMF0sIFsidDIzMDgiLCAxLjBdLCBbInQyMzA5IiwgMS4wXSwgWyJ0MjMxMCIsIDEuMF0sIFsidDIzMTEiLCAxLjBdLCBbInQyMzEyIiwgMS4wXSwgWyJ0MjMxMyIsIDEuMF0sIFsidDIzMTQiLCAxLjBdLCBbInQyMzE1IiwgMS4wXSwgWyJ0MjMxNiIsIDEuMF0sIFsidDIzMTciLCAxLjBdLCBbInQyMzE4IiwgMS4wXSwgWyJ0MjMxOSIsIDEuMF0sIFsidDIzMjAiLCAxLjBdLCBbInQyMzIxIiwgMS4wXSwgWyJ0MjMyMiIsIDEuMF0sIFsidDIzMjMiLCAxLjBdLCBbInQyMzI0IiwgMS4wXSwgWyJ0MjMyNSIsIDEuMF0sIFsidDIzMjYiLCAxLjBdLCBbInQyMzI3IiwgMS4wXSwgWyJ0MjMyOCIsIDEuMF0sIFsidDIzMjkiLCAxLjBdLCBbInQyMzMwIiwgMS4wXSwgWyJ0MjMzMSIsIDEuMF0sIFsidDIzMzIiLCAxLjBdLCBbInQyMzMzIiwgMS4wXSwgWyJ0MjMzNCIsIDEuMF0sIFsidDIzMzUiLCAxLjBdLCBbInQyMzM2IiwgMS4wXSwgWyJ0MjMzNyIsIDEuMF0sIFsidDIzMzgiLCAxLjBdLCBbInQyMzM5IiwgMS4wXSwgWyJ0MjM0MCIsIDEuMF0sIFsidDIzNDEiLCAxLjBdLCBbInQyMzQyIiwgMS4wXSwgWyJ0MjM0MyIsIDEuMF0sIFsidDIzNDQiLCAxLjBdLCBbInQyMzQ1IiwgMS4wXSwgWyJ0MjM0NiIsIDEuMF0sIFsidDIzNDciLCAxLjBdLCBbInQyMzQ4IiwgMS4wXSwgWyJ0MjM0OSIsIDEuMF0sIFsidDIzNTAiLCAxLjBdLCBbInQyMzUxIiwgMS4wXSwgWyJ0MjM1MiIsIDEuMF0sIFsidDIzNTMiLCAxLjBdLCBbInQyMzU0IiwgMS4wXSwgWyJ0MjM1NSIsIDEuMF0sIFsidDIzNTYiLCAxLjBdLCBbInQyMzU3IiwgMS4wXSwgWyJ0MjM1OCIsIDEuMF0sIFsidDIzNTkiLCAxLjBdLCBbInQyMzYwIiwgMS4wXSwgWyJ0MjM2MSIsIDEuMF0sIFsidDIzNjIiLCAxLjBdLCBbInQyMzYzIiwgMS4wXSwgWyJ0MjM2NCIsIDEuMF0sIFsidDIzNjUiLCAxLjBdLCBbInQyMzY2IiwgMS4wXSwgWyJ0MjM2NyIsIDEuMF0sIFsidDIzNjgiLCAxLjBdLCBbInQyMzY5IiwgMS4wXSwgWyJ0MjM3MCIsIDEuMF0sIFsidDIzNzEiLCAxLjBdLCBbInQyMzcyIiwgMS4wXSwgWyJ0MjM3MyIsIDEuMF0sIFsidDIzNzQiLCAxLjBdLCBbInQyMzc1IiwgMS4wXSwgWyJ0MjM3NiIsIDEuMF0sIFsidDIzNzciLCAxLjBdLCBbInQyMzc4IiwgMS4wXSwgWyJ0MjM3OSIsIDEuMF0sIFsidDIzODAiLCAxLjBdLCBbInQyMzgxIiwgMS4wXSwgWyJ0MjM4MiIsIDEuMF0sIFsidDIzODMiLCAxLjBdLCBbInQyMzg0IiwgMS4wXSwgWyJ0MjM4NSIsIDEuMF0sIFsidDIzODYiLCAxLjBdLCBbInQyMzg3IiwgMS4wXSwgWyJ0MjM4OCIsIDEuMF0sIFsidDIzODkiLCAxLjBdLCBbInQyMzkwIiwgMS4wXSwgWyJ0MjM5MSIsIDEuMF0sIFsidDIzOTIiLCAxLjBdLCBbInQyMzkzIiwgMS4wXSwgWyJ0MjM5NCIsIDEuMF0sIFsidDIzOTUiLCAxLjBdLCBbInQyMzk2IiwgMS4wXSwgWyJ0MjM5NyIsIDEuMF0sIFsidDIzOTgiLCAxLjBdLCBbInQyMzk5IiwgMS4wXSwgWyJ0MjQwMCIsIDEuMF0sIFsidDI0MDEiLCAxLjBdLCBbInQyNDAyIiwgMS4wXSwgWyJ0MjQwMyIsIDEuMF0sIFsidDI0MDQiLCAxLjBdLCBbInQyNDA1IiwgMS4wXSwgWyJ0MjQwNiIsIDEuMF0sIFsidDI0MDciLCAxLjBdLCBbInQyNDA4IiwgMS4wXSwgWyJ0MjQwOSIsIDEuMF0sIFsidDI0MTAiLCAxLjBdLCBbInQyNDExIiwgMS4wXSwgWyJ0MjQxMiIsIDEuMF0sIFsidDI0MTMiLCAxLjBdLCBbInQyNDE0IiwgMS4wXSwgWyJ0MjQxNSIsIDEuMF0sIFsidDI0MTYiLCAxLjBdLCBbInQyNDE3IiwgMS4wXSwgWyJ0MjQxOCIsIDEuMF0sIFsidDI0MTkiLCAxLjBdLCBbInQyNDIwIiwgMS4wXSwgWyJ0MjQyMSIsIDEuMF0sIFsidDI0MjIiLCAxLjBdLCBbInQyNDIzIiwgMS4wXSwgWyJ0MjQyNCIsIDEuMF0sIFsidDI0MjUiLCAxLjBdLCBbInQyNDI2IiwgMS4wXSwgWyJ0MjQyNyIsIDEuMF0sIFsidDI0MjgiLCAxLjBdLCBbInQyNDI5IiwgMS4wXSwgWyJ0MjQzMCIsIDEuMF0sIFsidDI0MzEiLCAxLjBdLCBbInQyNDMyIiwgMS4wXSwgWyJ0MjQzMyIsIDEuMF0sIFsidDI0MzQiLCAxLjBdLCBbInQyNDM1IiwgMS4wXSwgWyJ0MjQzNiIsIDEuMF0sIFsidDI0MzciLCAxLjBdLCBbInQyNDM4IiwgMS4wXSwgWyJ0MjQzOSIsIDEuMF0sIFsidDI0NDAiLCAxLjBdLCBbInQyNDQxIiwgMS4wXSwgWyJ0MjQ0MiIsIDEuMF0sIFsidDI0NDMiLCAxLjBdLCBbInQyNDQ0IiwgMS4wXSwgWyJ0MjQ0NSIsIDEuMF0sIFsidDI0NDYiLCAxLjBdLCBbInQyNDQ3IiwgMS4wXSwgWyJ0MjQ0OCIsIDEuMF0sIFsidDI0NDkiLCAxLjBdLCBbInQyNDUwIiwgMS4wXSwgWyJ0MjQ1MSIsIDEuMF0sIFsidDI0NTIiLCAxLjBdLCBbInQyNDUzIiwgMS4wXSwgWyJ0MjQ1NCIsIDEuMF0sIFsidDI0NTUiLCAxLjBdLCBbInQyNDU2IiwgMS4wXSwgWyJ0MjQ1NyIsIDEuMF0sIFsidDI0NTgiLCAxLjBdLCBbInQyNDU5IiwgMS4wXSwgWyJ0MjQ2MCIsIDEuMF0sIFsidDI0NjEiLCAxLjBdLCBbInQyNDYyIiwgMS4wXSwgWyJ0MjQ2MyIsIDEuMF0sIFsidDI0NjQiLCAxLjBdLCBbInQyNDY1IiwgMS4wXSwgWyJ0MjQ2NiIsIDEuMF0sIFsidDI0NjciLCAxLjBdLCBbInQyNDY4IiwgMS4wXSwgWyJ0MjQ2OSIsIDEuMF0sIFsidDI0NzAiLCAxLjBdLCBbInQyNDcxIiwgMS4wXSwgWyJ0MjQ3MiIsIDEuMF0sIFsidDI0NzMiLCAxLjBdLCBbInQyNDc0IiwgMS4wXSwgWyJ0MjQ3NSIsIDEuMF0sIFsidDI0NzYiLCAxLjBdLCBbInQyNDc3IiwgMS4wXSwgWyJ0MjQ3OCIsIDEuMF0sIFsidDI0NzkiLCAxLjBdLCBbInQyNDgwIiwgMS4wXSwgWyJ0MjQ4MSIsIDEuMF0sIFsidDI0ODIiLCAxLjBdLCBbInQyNDgzIiwgMS4wXSwgWyJ0MjQ4NCIsIDEuMF0sIFsidDI0ODUiLCAxLjBdLCBbInQyNDg2IiwgMS4wXSwgWyJ0MjQ4NyIsIDEuMF0sIFsidDI0ODgiLCAxLjBdLCBbInQyNDg5IiwgMS4wXSwgWyJ0MjQ5MCIsIDEuMF0sIFsidDI0OTEiLCAxLjBdLCBbInQyNDkyIiwgMS4wXSwgWyJ0MjQ5MyIsIDEuMF0sIFsidDI0OTQiLCAxLjBdLCBbInQyNDk1IiwgMS4wXSwgWyJ0MjQ5NiIsIDEuMF0sIFsidDI0OTciLCAxLjBdLCBbInQyNDk4IiwgMS4wXSwgWyJ0MjQ5OSIsIDEuMF0sIFsidDI1MDAiLCAxLjBdLCBbInQyNTAxIiwgMS4wXSwgWyJ0MjUwMiIsIDEuMF0sIFsidDI1MDMiLCAxLjBdLCBbInQyNTA0IiwgMS4wXSwgWyJ0MjUwNSIsIDEuMF0sIFsidDI1MDYiLCAxLjBdLCBbInQyNTA3IiwgMS4wXSwgWyJ0MjUwOCIsIDEuMF0sIFsidDI1MDkiLCAxLjBdLCBbInQyNTEwIiwgMS4wXSwgWyJ0MjUxMSIsIDEuMF0sIFsidDI1MTIiLCAxLjBdLCBbInQyNTEzIiwgMS4wXSwgWyJ0MjUxNCIsIDEuMF0sIFsidDI1MTUiLCAxLjBdLCBbInQyNTE2IiwgMS4wXSwgWyJ0MjUxNyIsIDEuMF0sIFsidDI1MTgiLCAxLjBdLCBbInQyNTE5IiwgMS4wXSwgWyJ0MjUyMCIsIDEuMF0sIFsidDI1MjEiLCAxLjBdLCBbInQyNTIyIiwgMS4wXSwgWyJ0MjUyMyIsIDEuMF0sIFsidDI1MjQiLCAxLjBdLCBbInQyNTI1IiwgMS4wXSwgWyJ0MjUyNiIsIDEuMF0sIFsidDI1MjciLCAxLjBdLCBbInQyNTI4IiwgMS4wXSwgWyJ0MjUyOSIsIDEuMF0sIFsidDI1MzAiLCAxLjBdLCBbInQyNTMxIiwgMS4wXSwgWyJ0MjUzMiIsIDEuMF0sIFsidDI1MzMiLCAxLjBdLCBbInQyNTM0IiwgMS4wXSwgWyJ0MjUzNSIsIDEuMF0sIFsidDI1MzYiLCAxLjBdLCBbInQyNTM3IiwgMS4wXSwgWyJ0MjUzOCIsIDEuMF0sIFsidDI1MzkiLCAxLjBdLCBbInQyNTQwIiwgMS4wXSwgWyJ0MjU0MSIsIDEuMF0sIFsidDI1NDIiLCAxLjBdLCBbInQyNTQzIiwgMS4wXSwgWyJ0MjU0NCIsIDEuMF0sIFsidDI1NDUiLCAxLjBdLCBbInQyNTQ2IiwgMS4wXSwgWyJ0MjU0NyIsIDEuMF0sIFsidDI1NDgiLCAxLjBdLCBbInQyNTQ5IiwgMS4wXSwgWyJ0MjU1MCIsIDEuMF0sIFsidDI1NTEiLCAxLjBdLCBbInQyNTUyIiwgMS4wXSwgWyJ0MjU1MyIsIDEuMF0sIFsidDI1NTQiLCAxLjBdLCBbInQyNTU1IiwgMS4wXSwgWyJ0MjU1NiIsIDEuMF0sIFsidDI1NTciLCAxLjBdLCBbInQyNTU4IiwgMS4wXSwgWyJ0MjU1OSIsIDEuMF0sIFsidDI1NjAiLCAxLjBdLCBbInQyNTYxIiwgMS4wXSwgWyJ0MjU2MiIsIDEuMF0sIFsidDI1NjMiLCAxLjBdLCBbInQyNTY0IiwgMS4wXSwgWyJ0MjU2NSIsIDEuMF0sIFsidDI1NjYiLCAxLjBdLCBbInQyNTY3IiwgMS4wXSwgWyJ0MjU2OCIsIDEuMF0sIFsidDI1NjkiLCAxLjBdLCBbInQyNTcwIiwgMS4wXSwgWyJ0MjU3MSIsIDEuMF0sIFsidDI1NzIiLCAxLjBdLCBbInQyNTczIiwgMS4wXSwgWyJ0MjU3NCIsIDEuMF0sIFsidDI1NzUiLCAxLjBdLCBbInQyNTc2IiwgMS4wXSwgWyJ0MjU3NyIsIDEuMF0sIFsidDI1NzgiLCAxLjBdLCBbInQyNTc5IiwgMS4wXSwgWyJ0MjU4MCIsIDEuMF0sIFsidDI1ODEiLCAxLjBdLCBbInQyNTgyIiwgMS4wXSwgWyJ0MjU4MyIsIDEuMF0sIFsidDI1ODQiLCAxLjBdLCBbInQyNTg1IiwgMS4wXSwgWyJ0MjU4NiIsIDEuMF0sIFsidDI1ODciLCAxLjBdLCBbInQyNTg4IiwgMS4wXSwgWyJ0MjU4OSIsIDEuMF0sIFsidDI1OTAiLCAxLjBdLCBbInQyNTkxIiwgMS4wXSwgWyJ0MjU5MiIsIDEuMF0sIFsidDI1OTMiLCAxLjBdLCBbInQyNTk0IiwgMS4wXSwgWyJ0MjU5NSIsIDEuMF0sIFsidDI1OTYiLCAxLjBdLCBbInQyNTk3IiwgMS4wXSwgWyJ0MjU5OCIsIDEuMF0sIFsidDI1OTkiLCAxLjBdLCBbInQyNjAwIiwgMS4wXSwgWyJ0MjYwMSIsIDEuMF0sIFsidDI2MDIiLCAxLjBdLCBbInQyNjAzIiwgMS4wXSwgWyJ0MjYwNCIsIDEuMF0sIFsidDI2MDUiLCAxLjBdLCBbInQyNjA2IiwgMS4wXSwgWyJ0MjYwNyIsIDEuMF0sIFsidDI2MDgiLCAxLjBdLCBbInQyNjA5IiwgMS4wXSwgWyJ0MjYxMCIsIDEuMF0sIFsidDI2MTEiLCAxLjBdLCBbInQyNjEyIiwgMS4wXSwgWyJ0MjYxMyIsIDEuMF0sIFsidDI2MTQiLCAxLjBdLCBbInQyNjE1IiwgMS4wXSwgWyJ0MjYxNiIsIDEuMF0sIFsidDI2MTciLCAxLjBdLCBbInQyNjE4IiwgMS4wXSwgWyJ0MjYxOSIsIDEuMF0sIFsidDI2MjAiLCAxLjBdLCBbInQyNjIxIiwgMS4wXSwgWyJ0MjYyMiIsIDEuMF0sIFsidDI2MjMiLCAxLjBdLCBbInQyNjI0IiwgMS4wXSwgWyJ0MjYyNSIsIDEuMF0sIFsidDI2MjYiLCAxLjBdLCBbInQyNjI3IiwgMS4wXSwgWyJ0MjYyOCIsIDEuMF0sIFsidDI2MjkiLCAxLjBdLCBbInQyNjMwIiwgMS4wXSwgWyJ0MjYzMSIsIDEuMF0sIFsidDI2MzIiLCAxLjBdLCBbInQyNjMzIiwgMS4wXSwgWyJ0MjYzNCIsIDEuMF0sIFsidDI2MzUiLCAxLjBdLCBbInQyNjM2IiwgMS4wXSwgWyJ0MjYzNyIsIDEuMF0sIFsidDI2MzgiLCAxLjBdLCBbInQyNjM5IiwgMS4wXSwgWyJ0MjY0MCIsIDEuMF0sIFsidDI2NDEiLCAxLjBdLCBbInQyNjQyIiwgMS4wXSwgWyJ0MjY0MyIsIDEuMF0sIFsidDI2NDQiLCAxLjBdLCBbInQyNjQ1IiwgMS4wXSwgWyJ0MjY0NiIsIDEuMF0sIFsidDI2NDciLCAxLjBdLCBbInQyNjQ4IiwgMS4wXSwgWyJ0MjY0OSIsIDEuMF0sIFsidDI2NTAiLCAxLjBdLCBbInQyNjUxIiwgMS4wXSwgWyJ0MjY1MiIsIDEuMF0sIFsidDI2NTMiLCAxLjBdLCBbInQyNjU0IiwgMS4wXSwgWyJ0MjY1NSIsIDEuMF0sIFsidDI2NTYiLCAxLjBdLCBbInQyNjU3IiwgMS4wXSwgWyJ0MjY1OCIsIDEuMF0sIFsidDI2NTkiLCAxLjBdLCBbInQyNjYwIiwgMS4wXSwgWyJ0MjY2MSIsIDEuMF0sIFsidDI2NjIiLCAxLjBdLCBbInQyNjYzIiwgMS4wXSwgWyJ0MjY2NCIsIDEuMF0sIFsidDI2NjUiLCAxLjBdLCBbInQyNjY2IiwgMS4wXSwgWyJ0MjY2NyIsIDEuMF0sIFsidDI2NjgiLCAxLjBdLCBbInQyNjY5IiwgMS4wXSwgWyJ0MjY3MCIsIDEuMF0sIFsidDI2NzEiLCAxLjBdLCBbInQyNjcyIiwgMS4wXSwgWyJ0MjY3MyIsIDEuMF0sIFsidDI2NzQiLCAxLjBdLCBbInQyNjc1IiwgMS4wXSwgWyJ0MjY3NiIsIDEuMF0sIFsidDI2NzciLCAxLjBdLCBbInQyNjc4IiwgMS4wXSwgWyJ0MjY3OSIsIDEuMF0sIFsidDI2ODAiLCAxLjBdLCBbInQyNjgxIiwgMS4wXSwgWyJ0MjY4MiIsIDEuMF0sIFsidDI2ODMiLCAxLjBdLCBbInQyNjg0IiwgMS4wXSwgWyJ0MjY4NSIsIDEuMF0sIFsidDI2ODYiLCAxLjBdLCBbInQyNjg3IiwgMS4wXSwgWyJ0MjY4OCIsIDEuMF0sIFsidDI2ODkiLCAxLjBdLCBbInQyNjkwIiwgMS4wXSwgWyJ0MjY5MSIsIDEuMF0sIFsidDI2OTIiLCAxLjBdLCBbInQyNjkzIiwgMS4wXSwgWyJ0MjY5NCIsIDEuMF0sIFsidDI2OTUiLCAxLjBdLCBbInQyNjk2IiwgMS4wXSwgWyJ0MjY5NyIsIDEuMF0sIFsidDI2OTgiLCAxLjBdLCBbInQyNjk5IiwgMS4wXSwgWyJ0MjcwMCIsIDEuMF0sIFsidDI3MDEiLCAxLjBdLCBbInQyNzAyIiwgMS4wXSwgWyJ0MjcwMyIsIDEuMF0sIFsidDI3MDQiLCAxLjBdLCBbInQyNzA1IiwgMS4wXSwgWyJ0MjcwNiIsIDEuMF0sIFsidDI3MDciLCAxLjBdLCBbInQyNzA4IiwgMS4wXSwgWyJ0MjcwOSIsIDEuMF0sIFsidDI3MTAiLCAxLjBdLCBbInQyNzExIiwgMS4wXSwgWyJ0MjcxMiIsIDEuMF0sIFsidDI3MTMiLCAxLjBdLCBbInQyNzE0IiwgMS4wXSwgWyJ0MjcxNSIsIDEuMF0sIFsidDI3MTYiLCAxLjBdLCBbInQyNzE3IiwgMS4wXSwgWyJ0MjcxOCIsIDEuMF0sIFsidDI3MTkiLCAxLjBdLCBbInQyNzIwIiwgMS4wXSwgWyJ0MjcyMSIsIDEuMF0sIFsidDI3MjIiLCAxLjBdLCBbInQyNzIzIiwgMS4wXSwgWyJ0MjcyNCIsIDEuMF0sIFsidDI3MjUiLCAxLjBdLCBbInQyNzI2IiwgMS4wXSwgWyJ0MjcyNyIsIDEuMF0sIFsidDI3MjgiLCAxLjBdLCBbInQyNzI5IiwgMS4wXSwgWyJ0MjczMCIsIDEuMF0sIFsidDI3MzEiLCAxLjBdLCBbInQyNzMyIiwgMS4wXSwgWyJ0MjczMyIsIDEuMF0sIFsidDI3MzQiLCAxLjBdLCBbInQyNzM1IiwgMS4wXSwgWyJ0MjczNiIsIDEuMF0sIFsidDI3MzciLCAxLjBdLCBbInQyNzM4IiwgMS4wXSwgWyJ0MjczOSIsIDEuMF0sIFsidDI3NDAiLCAxLjBdLCBbInQyNzQxIiwgMS4wXSwgWyJ0Mjc0MiIsIDEuMF0sIFsidDI3NDMiLCAxLjBdLCBbInQyNzQ0IiwgMS4wXSwgWyJ0Mjc0NSIsIDEuMF0sIFsidDI3NDYiLCAxLjBdLCBbInQyNzQ3IiwgMS4wXSwgWyJ0Mjc0OCIsIDEuMF0sIFsidDI3NDkiLCAxLjBdLCBbInQyNzUwIiwgMS4wXSwgWyJ0Mjc1MSIsIDEuMF0sIFsidDI3NTIiLCAxLjBdLCBbInQyNzUzIiwgMS4wXSwgWyJ0Mjc1NCIsIDEuMF0sIFsidDI3NTUiLCAxLjBdLCBbInQyNzU2IiwgMS4wXSwgWyJ0Mjc1NyIsIDEuMF0sIFsidDI3NTgiLCAxLjBdLCBbInQyNzU5IiwgMS4wXSwgWyJ0Mjc2MCIsIDEuMF0sIFsidDI3NjEiLCAxLjBdLCBbInQyNzYyIiwgMS4wXSwgWyJ0Mjc2MyIsIDEuMF0sIFsidDI3NjQiLCAxLjBdLCBbInQyNzY1IiwgMS4wXSwgWyJ0Mjc2NiIsIDEuMF0sIFsidDI3NjciLCAxLjBdLCBbInQyNzY4IiwgMS4wXSwgWyJ0Mjc2OSIsIDEuMF0sIFsidDI3NzAiLCAxLjBdLCBbInQyNzcxIiwgMS4wXSwgWyJ0Mjc3MiIsIDEuMF0sIFsidDI3NzMiLCAxLjBdLCBbInQyNzc0IiwgMS4wXSwgWyJ0Mjc3NSIsIDEuMF0sIFsidDI3NzYiLCAxLjBdLCBbInQyNzc3IiwgMS4wXSwgWyJ0Mjc3OCIsIDEuMF0sIFsidDI3NzkiLCAxLjBdLCBbInQyNzgwIiwgMS4wXSwgWyJ0Mjc4MSIsIDEuMF0sIFsidDI3ODIiLCAxLjBdLCBbInQyNzgzIiwgMS4wXSwgWyJ0Mjc4NCIsIDEuMF0sIFsidDI3ODUiLCAxLjBdLCBbInQyNzg2IiwgMS4wXSwgWyJ0Mjc4NyIsIDEuMF0sIFsidDI3ODgiLCAxLjBdLCBbInQyNzg5IiwgMS4wXSwgWyJ0Mjc5MCIsIDEuMF0sIFsidDI3OTEiLCAxLjBdLCBbInQyNzkyIiwgMS4wXSwgWyJ0Mjc5MyIsIDEuMF0sIFsidDI3OTQiLCAxLjBdLCBbInQyNzk1IiwgMS4wXSwgWyJ0Mjc5NiIsIDEuMF0sIFsidDI3OTciLCAxLjBdLCBbInQyNzk4IiwgMS4wXSwgWyJ0Mjc5OSIsIDEuMF0sIFsidDI4MDAiLCAxLjBdLCBbInQyODAxIiwgMS4wXSwgWyJ0MjgwMiIsIDEuMF0sIFsidDI4MDMiLCAxLjBdLCBbInQyODA0IiwgMS4wXSwgWyJ0MjgwNSIsIDEuMF0sIFsidDI4MDYiLCAxLjBdLCBbInQyODA3IiwgMS4wXSwgWyJ0MjgwOCIsIDEuMF0sIFsidDI4MDkiLCAxLjBdLCBbInQyODEwIiwgMS4wXSwgWyJ0MjgxMSIsIDEuMF0sIFsidDI4MTIiLCAxLjBdLCBbInQyODEzIiwgMS4wXSwgWyJ0MjgxNCIsIDEuMF0sIFsidDI4MTUiLCAxLjBdLCBbInQyODE2IiwgMS4wXSwgWyJ0MjgxNyIsIDEuMF0sIFsidDI4MTgiLCAxLjBdLCBbInQyODE5IiwgMS4wXSwgWyJ0MjgyMCIsIDEuMF0sIFsidDI4MjEiLCAxLjBdLCBbInQyODIyIiwgMS4wXSwgWyJ0MjgyMyIsIDEuMF0sIFsidDI4MjQiLCAxLjBdLCBbInQyODI1IiwgMS4wXSwgWyJ0MjgyNiIsIDEuMF0sIFsidDI4MjciLCAxLjBdLCBbInQyODI4IiwgMS4wXSwgWyJ0MjgyOSIsIDEuMF0sIFsidDI4MzAiLCAxLjBdLCBbInQyODMxIiwgMS4wXSwgWyJ0MjgzMiIsIDEuMF0sIFsidDI4MzMiLCAxLjBdLCBbInQyODM0IiwgMS4wXSwgWyJ0MjgzNSIsIDEuMF0sIFsidDI4MzYiLCAxLjBdLCBbInQyODM3IiwgMS4wXSwgWyJ0MjgzOCIsIDEuMF0sIFsidDI4MzkiLCAxLjBdLCBbInQyODQwIiwgMS4wXSwgWyJ0Mjg0MSIsIDEuMF0sIFsidDI4NDIiLCAxLjBdLCBbInQyODQzIiwgMS4wXSwgWyJ0Mjg0NCIsIDEuMF0sIFsidDI4NDUiLCAxLjBdLCBbInQyODQ2IiwgMS4wXSwgWyJ0Mjg0NyIsIDEuMF0sIFsidDI4NDgiLCAxLjBdLCBbInQyODQ5IiwgMS4wXSwgWyJ0Mjg1MCIsIDEuMF0sIFsidDI4NTEiLCAxLjBdLCBbInQyODUyIiwgMS4wXSwgWyJ0Mjg1MyIsIDEuMF0sIFsidDI4NTQiLCAxLjBdLCBbInQyODU1IiwgMS4wXSwgWyJ0Mjg1NiIsIDEuMF0sIFsidDI4NTciLCAxLjBdLCBbInQyODU4IiwgMS4wXSwgWyJ0Mjg1OSIsIDEuMF0sIFsidDI4NjAiLCAxLjBdLCBbInQyODYxIiwgMS4wXSwgWyJ0Mjg2MiIsIDEuMF0sIFsidDI4NjMiLCAxLjBdLCBbInQyODY0IiwgMS4wXSwgWyJ0Mjg2NSIsIDEuMF0sIFsidDI4NjYiLCAxLjBdLCBbInQyODY3IiwgMS4wXSwgWyJ0Mjg2OCIsIDEuMF0sIFsidDI4NjkiLCAxLjBdLCBbInQyODcwIiwgMS4wXSwgWyJ0Mjg3MSIsIDEuMF0sIFsidDI4NzIiLCAxLjBdLCBbInQyODczIiwgMS4wXSwgWyJ0Mjg3NCIsIDEuMF0sIFsidDI4NzUiLCAxLjBdLCBbInQyODc2IiwgMS4wXSwgWyJ0Mjg3NyIsIDEuMF0sIFsidDI4NzgiLCAxLjBdLCBbInQyODc5IiwgMS4wXSwgWyJ0Mjg4MCIsIDEuMF0sIFsidDI4ODEiLCAxLjBdLCBbInQyODgyIiwgMS4wXSwgWyJ0Mjg4MyIsIDEuMF0sIFsidDI4ODQiLCAxLjBdLCBbInQyODg1IiwgMS4wXSwgWyJ0Mjg4NiIsIDEuMF0sIFsidDI4ODciLCAxLjBdLCBbInQyODg4IiwgMS4wXSwgWyJ0Mjg4OSIsIDEuMF0sIFsidDI4OTAiLCAxLjBdLCBbInQyODkxIiwgMS4wXSwgWyJ0Mjg5MiIsIDEuMF0sIFsidDI4OTMiLCAxLjBdLCBbInQyODk0IiwgMS4wXSwgWyJ0Mjg5NSIsIDEuMF0sIFsidDI4OTYiLCAxLjBdLCBbInQyODk3IiwgMS4wXSwgWyJ0Mjg5OCIsIDEuMF0sIFsidDI4OTkiLCAxLjBdLCBbInQyOTAwIiwgMS4wXSwgWyJ0MjkwMSIsIDEuMF0sIFsidDI5MDIiLCAxLjBdLCBbInQyOTAzIiwgMS4wXSwgWyJ0MjkwNCIsIDEuMF0sIFsidDI5MDUiLCAxLjBdLCBbInQyOTA2IiwgMS4wXSwgWyJ0MjkwNyIsIDEuMF0sIFsidDI5MDgiLCAxLjBdLCBbInQyOTA5IiwgMS4wXSwgWyJ0MjkxMCIsIDEuMF0sIFsidDI5MTEiLCAxLjBdLCBbInQyOTEyIiwgMS4wXSwgWyJ0MjkxMyIsIDEuMF0sIFsidDI5MTQiLCAxLjBdLCBbInQyOTE1IiwgMS4wXSwgWyJ0MjkxNiIsIDEuMF0sIFsidDI5MTciLCAxLjBdLCBbInQyOTE4IiwgMS4wXSwgWyJ0MjkxOSIsIDEuMF0sIFsidDI5MjAiLCAxLjBdLCBbInQyOTIxIiwgMS4wXSwgWyJ0MjkyMiIsIDEuMF0sIFsidDI5MjMiLCAxLjBdLCBbInQyOTI0IiwgMS4wXSwgWyJ0MjkyNSIsIDEuMF0sIFsidDI5MjYiLCAxLjBdLCBbInQyOTI3IiwgMS4wXSwgWyJ0MjkyOCIsIDEuMF0sIFsidDI5MjkiLCAxLjBdLCBbInQyOTMwIiwgMS4wXSwgWyJ0MjkzMSIsIDEuMF0sIFsidDI5MzIiLCAxLjBdLCBbInQyOTMzIiwgMS4wXSwgWyJ0MjkzNCIsIDEuMF0sIFsidDI5MzUiLCAxLjBdLCBbInQyOTM2IiwgMS4wXSwgWyJ0MjkzNyIsIDEuMF0sIFsidDI5MzgiLCAxLjBdLCBbInQyOTM5IiwgMS4wXSwgWyJ0Mjk0MCIsIDEuMF0sIFsidDI5NDEiLCAxLjBdLCBbInQyOTQyIiwgMS4wXSwgWyJ0Mjk0MyIsIDEuMF0sIFsidDI5NDQiLCAxLjBdLCBbInQyOTQ1IiwgMS4wXSwgWyJ0Mjk0NiIsIDEuMF0sIFsidDI5NDciLCAxLjBdLCBbInQyOTQ4IiwgMS4wXSwgWyJ0Mjk0OSIsIDEuMF0sIFsidDI5NTAiLCAxLjBdLCBbInQyOTUxIiwgMS4wXSwgWyJ0Mjk1MiIsIDEuMF0sIFsidDI5NTMiLCAxLjBdLCBbInQyOTU0IiwgMS4wXSwgWyJ0Mjk1NSIsIDEuMF0sIFsidDI5NTYiLCAxLjBdLCBbInQyOTU3IiwgMS4wXSwgWyJ0Mjk1OCIsIDEuMF0sIFsidDI5NTkiLCAxLjBdLCBbInQyOTYwIiwgMS4wXSwgWyJ0Mjk2MSIsIDEuMF0sIFsidDI5NjIiLCAxLjBdLCBbInQyOTYzIiwgMS4wXSwgWyJ0Mjk2NCIsIDEuMF0sIFsidDI5NjUiLCAxLjBdLCBbInQyOTY2IiwgMS4wXSwgWyJ0Mjk2NyIsIDEuMF0sIFsidDI5NjgiLCAxLjBdLCBbInQyOTY5IiwgMS4wXSwgWyJ0Mjk3MCIsIDEuMF0sIFsidDI5NzEiLCAxLjBdLCBbInQyOTcyIiwgMS4wXSwgWyJ0Mjk3MyIsIDEuMF0sIFsidDI5NzQiLCAxLjBdLCBbInQyOTc1IiwgMS4wXSwgWyJ0Mjk3NiIsIDEuMF0sIFsidDI5NzciLCAxLjBdLCBbInQyOTc4IiwgMS4wXSwgWyJ0Mjk3OSIsIDEuMF0sIFsidDI5ODAiLCAxLjBdLCBbInQyOTgxIiwgMS4wXSwgWyJ0Mjk4MiIsIDEuMF0sIFsidDI5ODMiLCAxLjBdLCBbInQyOTg0IiwgMS4wXSwgWyJ0Mjk4NSIsIDEuMF0sIFsidDI5ODYiLCAxLjBdLCBbInQyOTg3IiwgMS4wXSwgWyJ0Mjk4OCIsIDEuMF0sIFsidDI5ODkiLCAxLjBdLCBbInQyOTkwIiwgMS4wXSwgWyJ0Mjk5MSIsIDEuMF0sIFsidDI5OTIiLCAxLjBdLCBbInQyOTkzIiwgMS4wXSwgWyJ0Mjk5NCIsIDEuMF0sIFsidDI5OTUiLCAxLjBdLCBbInQyOTk2IiwgMS4wXSwgWyJ0Mjk5NyIsIDEuMF0sIFsidDI5OTgiLCAxLjBdLCBbInQyOTk5IiwgMS4wXSwgWyJ0MzAwMCIsIDEuMF0sIFsidDMwMDEiLCAxLjBdLCBbInQzMDAyIiwgMS4wXSwgWyJ0MzAwMyIsIDEuMF0sIFsidDMwMDQiLCAxLjBdLCBbInQzMDA1IiwgMS4wXSwgWyJ0MzAwNiIsIDEuMF0sIFsidDMwMDciLCAxLjBdLCBbInQzMDA4IiwgMS4wXSwgWyJ0MzAwOSIsIDEuMF0sIFsidDMwMTAiLCAxLjBdLCBbInQzMDExIiwgMS4wXSwgWyJ0MzAxMiIsIDEuMF0sIFsidDMwMTMiLCAxLjBdLCBbInQzMDE0IiwgMS4wXSwgWyJ0MzAxNSIsIDEuMF0sIFsidDMwMTYiLCAxLjBdLCBbInQzMDE3IiwgMS4wXSwgWyJ0MzAxOCIsIDEuMF0sIFsidDMwMTkiLCAxLjBdLCBbInQzMDIwIiwgMS4wXSwgWyJ0MzAyMSIsIDEuMF0sIFsidDMwMjIiLCAxLjBdLCBbInQzMDIzIiwgMS4wXSwgWyJ0MzAyNCIsIDEuMF0sIFsidDMwMjUiLCAxLjBdLCBbInQzMDI2IiwgMS4wXSwgWyJ0MzAyNyIsIDEuMF0sIFsidDMwMjgiLCAxLjBdLCBbInQzMDI5IiwgMS4wXSwgWyJ0MzAzMCIsIDEuMF0sIFsidDMwMzEiLCAxLjBdLCBbInQzMDMyIiwgMS4wXSwgWyJ0MzAzMyIsIDEuMF0sIFsidDMwMzQiLCAxLjBdLCBbInQzMDM1IiwgMS4wXSwgWyJ0MzAzNiIsIDEuMF0sIFsidDMwMzciLCAxLjBdLCBbInQzMDM4IiwgMS4wXSwgWyJ0MzAzOSIsIDEuMF0sIFsidDMwNDAiLCAxLjBdLCBbInQzMDQxIiwgMS4wXSwgWyJ0MzA0MiIsIDEuMF0sIFsidDMwNDMiLCAxLjBdLCBbInQzMDQ0IiwgMS4wXSwgWyJ0MzA0NSIsIDEuMF0sIFsidDMwNDYiLCAxLjBdLCBbInQzMDQ3IiwgMS4wXSwgWyJ0MzA0OCIsIDEuMF0sIFsidDMwNDkiLCAxLjBdLCBbInQzMDUwIiwgMS4wXSwgWyJ0MzA1MSIsIDEuMF0sIFsidDMwNTIiLCAxLjBdLCBbInQzMDUzIiwgMS4wXSwgWyJ0MzA1NCIsIDEuMF0sIFsidDMwNTUiLCAxLjBdLCBbInQzMDU2IiwgMS4wXSwgWyJ0MzA1NyIsIDEuMF0sIFsidDMwNTgiLCAxLjBdLCBbInQzMDU5IiwgMS4wXSwgWyJ0MzA2MCIsIDEuMF0sIFsidDMwNjEiLCAxLjBdLCBbInQzMDYyIiwgMS4wXSwgWyJ0MzA2MyIsIDEuMF0sIFsidDMwNjQiLCAxLjBdLCBbInQzMDY1IiwgMS4wXSwgWyJ0MzA2NiIsIDEuMF0sIFsidDMwNjciLCAxLjBdLCBbInQzMDY4IiwgMS4wXSwgWyJ0MzA2OSIsIDEuMF0sIFsidDMwNzAiLCAxLjBdLCBbInQzMDcxIiwgMS4wXSwgWyJ0MzA3MiIsIDEuMF0sIFsidDMwNzMiLCAxLjBdLCBbInQzMDc0IiwgMS4wXSwgWyJ0MzA3NSIsIDEuMF0sIFsidDMwNzYiLCAxLjBdLCBbInQzMDc3IiwgMS4wXSwgWyJ0MzA3OCIsIDEuMF0sIFsidDMwNzkiLCAxLjBdLCBbInQzMDgwIiwgMS4wXSwgWyJ0MzA4MSIsIDEuMF0sIFsidDMwODIiLCAxLjBdLCBbInQzMDgzIiwgMS4wXSwgWyJ0MzA4NCIsIDEuMF0sIFsidDMwODUiLCAxLjBdLCBbInQzMDg2IiwgMS4wXSwgWyJ0MzA4NyIsIDEuMF0sIFsidDMwODgiLCAxLjBdLCBbInQzMDg5IiwgMS4wXSwgWyJ0MzA5MCIsIDEuMF0sIFsidDMwOTEiLCAxLjBdLCBbInQzMDkyIiwgMS4wXSwgWyJ0MzA5MyIsIDEuMF0sIFsidDMwOTQiLCAxLjBdLCBbInQzMDk1IiwgMS4wXSwgWyJ0MzA5NiIsIDEuMF0sIFsidDMwOTciLCAxLjBdLCBbInQzMDk4IiwgMS4wXSwgWyJ0MzA5OSIsIDEuMF0sIFsidDMxMDAiLCAxLjBdLCBbInQzMTAxIiwgMS4wXSwgWyJ0MzEwMiIsIDEuMF0sIFsidDMxMDMiLCAxLjBdLCBbInQzMTA0IiwgMS4wXSwgWyJ0MzEwNSIsIDEuMF0sIFsidDMxMDYiLCAxLjBdLCBbInQzMTA3IiwgMS4wXSwgWyJ0MzEwOCIsIDEuMF0sIFsidDMxMDkiLCAxLjBdLCBbInQzMTEwIiwgMS4wXSwgWyJ0MzExMSIsIDEuMF0sIFsidDMxMTIiLCAxLjBdLCBbInQzMTEzIiwgMS4wXSwgWyJ0MzExNCIsIDEuMF0sIFsidDMxMTUiLCAxLjBdLCBbInQzMTE2IiwgMS4wXSwgWyJ0MzExNyIsIDEuMF0sIFsidDMxMTgiLCAxLjBdLCBbInQzMTE5IiwgMS4wXSwgWyJ0MzEyMCIsIDEuMF0sIFsidDMxMjEiLCAxLjBdLCBbInQzMTIyIiwgMS4wXSwgWyJ0MzEyMyIsIDEuMF0sIFsidDMxMjQiLCAxLjBdLCBbInQzMTI1IiwgMS4wXSwgWyJ0MzEyNiIsIDEuMF0sIFsidDMxMjciLCAxLjBdLCBbInQzMTI4IiwgMS4wXSwgWyJ0MzEyOSIsIDEuMF0sIFsidDMxMzAiLCAxLjBdLCBbInQzMTMxIiwgMS4wXSwgWyJ0MzEzMiIsIDEuMF0sIFsidDMxMzMiLCAxLjBdLCBbInQzMTM0IiwgMS4wXSwgWyJ0MzEzNSIsIDEuMF0sIFsidDMxMzYiLCAxLjBdLCBbInQzMTM3IiwgMS4wXSwgWyJ0MzEzOCIsIDEuMF0sIFsidDMxMzkiLCAxLjBdLCBbInQzMTQwIiwgMS4wXSwgWyJ0MzE0MSIsIDEuMF0sIFsidDMxNDIiLCAxLjBdLCBbInQzMTQzIiwgMS4wXSwgWyJ0MzE0NCIsIDEuMF0sIFsidDMxNDUiLCAxLjBdLCBbInQzMTQ2IiwgMS4wXSwgWyJ0MzE0NyIsIDEuMF0sIFsidDMxNDgiLCAxLjBdLCBbInQzMTQ5IiwgMS4wXSwgWyJ0MzE1MCIsIDEuMF0sIFsidDMxNTEiLCAxLjBdLCBbInQzMTUyIiwgMS4wXSwgWyJ0MzE1MyIsIDEuMF0sIFsidDMxNTQiLCAxLjBdLCBbInQzMTU1IiwgMS4wXSwgWyJ0MzE1NiIsIDEuMF0sIFsidDMxNTciLCAxLjBdLCBbInQzMTU4IiwgMS4wXSwgWyJ0MzE1OSIsIDEuMF0sIFsidDMxNjAiLCAxLjBdLCBbInQzMTYxIiwgMS4wXSwgWyJ0MzE2MiIsIDEuMF0sIFsidDMxNjMiLCAxLjBdLCBbInQzMTY0IiwgMS4wXSwgWyJ0MzE2NSIsIDEuMF0sIFsidDMxNjYiLCAxLjBdLCBbInQzMTY3IiwgMS4wXSwgWyJ0MzE2OCIsIDEuMF0sIFsidDMxNjkiLCAxLjBdLCBbInQzMTcwIiwgMS4wXSwgWyJ0MzE3MSIsIDEuMF0sIFsidDMxNzIiLCAxLjBdLCBbInQzMTczIiwgMS4wXSwgWyJ0MzE3NCIsIDEuMF0sIFsidDMxNzUiLCAxLjBdLCBbInQzMTc2IiwgMS4wXSwgWyJ0MzE3NyIsIDEuMF0sIFsidDMxNzgiLCAxLjBdLCBbInQzMTc5IiwgMS4wXSwgWyJ0MzE4MCIsIDEuMF0sIFsidDMxODEiLCAxLjBdLCBbInQzMTgyIiwgMS4wXSwgWyJ0MzE4MyIsIDEuMF0sIFsidDMxODQiLCAxLjBdLCBbInQzMTg1IiwgMS4wXSwgWyJ0MzE4NiIsIDEuMF0sIFsidDMxODciLCAxLjBdLCBbInQzMTg4IiwgMS4wXSwgWyJ0MzE4OSIsIDEuMF0sIFsidDMxOTAiLCAxLjBdLCBbInQzMTkxIiwgMS4wXSwgWyJ0MzE5MiIsIDEuMF0sIFsidDMxOTMiLCAxLjBdLCBbInQzMTk0IiwgMS4wXSwgWyJ0MzE5NSIsIDEuMF0sIFsidDMxOTYiLCAxLjBdLCBbInQzMTk3IiwgMS4wXSwgWyJ0MzE5OCIsIDEuMF0sIFsidDMxOTkiLCAxLjBdLCBbInQzMjAwIiwgMS4wXSwgWyJ0MzIwMSIsIDEuMF0sIFsidDMyMDIiLCAxLjBdLCBbInQzMjAzIiwgMS4wXSwgWyJ0MzIwNCIsIDEuMF0sIFsidDMyMDUiLCAxLjBdLCBbInQzMjA2IiwgMS4wXSwgWyJ0MzIwNyIsIDEuMF0sIFsidDMyMDgiLCAxLjBdLCBbInQzMjA5IiwgMS4wXSwgWyJ0MzIxMCIsIDEuMF0sIFsidDMyMTEiLCAxLjBdLCBbInQzMjEyIiwgMS4wXSwgWyJ0MzIxMyIsIDEuMF0sIFsidDMyMTQiLCAxLjBdLCBbInQzMjE1IiwgMS4wXSwgWyJ0MzIxNiIsIDEuMF0sIFsidDMyMTciLCAxLjBdLCBbInQzMjE4IiwgMS4wXSwgWyJ0MzIxOSIsIDEuMF0sIFsidDMyMjAiLCAxLjBdLCBbInQzMjIxIiwgMS4wXSwgWyJ0MzIyMiIsIDEuMF0sIFsidDMyMjMiLCAxLjBdLCBbInQzMjI0IiwgMS4wXSwgWyJ0MzIyNSIsIDEuMF0sIFsidDMyMjYiLCAxLjBdLCBbInQzMjI3IiwgMS4wXSwgWyJ0MzIyOCIsIDEuMF0sIFsidDMyMjkiLCAxLjBdLCBbInQzMjMwIiwgMS4wXSwgWyJ0MzIzMSIsIDEuMF0sIFsidDMyMzIiLCAxLjBdLCBbInQzMjMzIiwgMS4wXSwgWyJ0MzIzNCIsIDEuMF0sIFsidDMyMzUiLCAxLjBdLCBbInQzMjM2IiwgMS4wXSwgWyJ0MzIzNyIsIDEuMF0sIFsidDMyMzgiLCAxLjBdLCBbInQzMjM5IiwgMS4wXSwgWyJ0MzI0MCIsIDEuMF0sIFsidDMyNDEiLCAxLjBdLCBbInQzMjQyIiwgMS4wXSwgWyJ0MzI0MyIsIDEuMF0sIFsidDMyNDQiLCAxLjBdLCBbInQzMjQ1IiwgMS4wXSwgWyJ0MzI0NiIsIDEuMF0sIFsidDMyNDciLCAxLjBdLCBbInQzMjQ4IiwgMS4wXSwgWyJ0MzI0OSIsIDEuMF0sIFsidDMyNTAiLCAxLjBdLCBbInQzMjUxIiwgMS4wXSwgWyJ0MzI1MiIsIDEuMF0sIFsidDMyNTMiLCAxLjBdLCBbInQzMjU0IiwgMS4wXSwgWyJ0MzI1NSIsIDEuMF0sIFsidDMyNTYiLCAxLjBdLCBbInQzMjU3IiwgMS4wXSwgWyJ0MzI1OCIsIDEuMF0sIFsidDMyNTkiLCAxLjBdLCBbInQzMjYwIiwgMS4wXSwgWyJ0MzI2MSIsIDEuMF0sIFsidDMyNjIiLCAxLjBdLCBbInQzMjYzIiwgMS4wXSwgWyJ0MzI2NCIsIDEuMF0sIFsidDMyNjUiLCAxLjBdLCBbInQzMjY2IiwgMS4wXSwgWyJ0MzI2NyIsIDEuMF0sIFsidDMyNjgiLCAxLjBdLCBbInQzMjY5IiwgMS4wXSwgWyJ0MzI3MCIsIDEuMF0sIFsidDMyNzEiLCAxLjBdLCBbInQzMjcyIiwgMS4wXSwgWyJ0MzI3MyIsIDEuMF0sIFsidDMyNzQiLCAxLjBdLCBbInQzMjc1IiwgMS4wXSwgWyJ0MzI3NiIsIDEuMF0sIFsidDMyNzciLCAxLjBdLCBbInQzMjc4IiwgMS4wXSwgWyJ0MzI3OSIsIDEuMF0sIFsidDMyODAiLCAxLjBdLCBbInQzMjgxIiwgMS4wXSwgWyJ0MzI4MiIsIDEuMF0sIFsidDMyODMiLCAxLjBdLCBbInQzMjg0IiwgMS4wXSwgWyJ0MzI4NSIsIDEuMF0sIFsidDMyODYiLCAxLjBdLCBbInQzMjg3IiwgMS4wXSwgWyJ0MzI4OCIsIDEuMF0sIFsidDMyODkiLCAxLjBdLCBbInQzMjkwIiwgMS4wXSwgWyJ0MzI5MSIsIDEuMF0sIFsidDMyOTIiLCAxLjBdLCBbInQzMjkzIiwgMS4wXSwgWyJ0MzI5NCIsIDEuMF0sIFsidDMyOTUiLCAxLjBdLCBbInQzMjk2IiwgMS4wXSwgWyJ0MzI5NyIsIDEuMF0sIFsidDMyOTgiLCAxLjBdLCBbInQzMjk5IiwgMS4wXSwgWyJ0MzMwMCIsIDEuMF0sIFsidDMzMDEiLCAxLjBdLCBbInQzMzAyIiwgMS4wXSwgWyJ0MzMwMyIsIDEuMF0sIFsidDMzMDQiLCAxLjBdLCBbInQzMzA1IiwgMS4wXSwgWyJ0MzMwNiIsIDEuMF0sIFsidDMzMDciLCAxLjBdLCBbInQzMzA4IiwgMS4wXSwgWyJ0MzMwOSIsIDEuMF0sIFsidDMzMTAiLCAxLjBdLCBbInQzMzExIiwgMS4wXSwgWyJ0MzMxMiIsIDEuMF0sIFsidDMzMTMiLCAxLjBdLCBbInQzMzE0IiwgMS4wXSwgWyJ0MzMxNSIsIDEuMF0sIFsidDMzMTYiLCAxLjBdLCBbInQzMzE3IiwgMS4wXSwgWyJ0MzMxOCIsIDEuMF0sIFsidDMzMTkiLCAxLjBdLCBbInQzMzIwIiwgMS4wXSwgWyJ0MzMyMSIsIDEuMF0sIFsidDMzMjIiLCAxLjBdLCBbInQzMzIzIiwgMS4wXSwgWyJ0MzMyNCIsIDEuMF0sIFsidDMzMjUiLCAxLjBdLCBbInQzMzI2IiwgMS4wXSwgWyJ0MzMyNyIsIDEuMF0sIFsidDMzMjgiLCAxLjBdLCBbInQzMzI5IiwgMS4wXSwgWyJ0MzMzMCIsIDEuMF0sIFsidDMzMzEiLCAxLjBdLCBbInQzMzMyIiwgMS4wXSwgWyJ0MzMzMyIsIDEuMF0sIFsidDMzMzQiLCAxLjBdLCBbInQzMzM1IiwgMS4wXSwgWyJ0MzMzNiIsIDEuMF0sIFsidDMzMzciLCAxLjBdLCBbInQzMzM4IiwgMS4wXSwgWyJ0MzMzOSIsIDEuMF0sIFsidDMzNDAiLCAxLjBdLCBbInQzMzQxIiwgMS4wXSwgWyJ0MzM0MiIsIDEuMF0sIFsidDMzNDMiLCAxLjBdLCBbInQzMzQ0IiwgMS4wXSwgWyJ0MzM0NSIsIDEuMF0sIFsidDMzNDYiLCAxLjBdLCBbInQzMzQ3IiwgMS4wXSwgWyJ0MzM0OCIsIDEuMF0sIFsidDMzNDkiLCAxLjBdLCBbInQzMzUwIiwgMS4wXSwgWyJ0MzM1MSIsIDEuMF0sIFsidDMzNTIiLCAxLjBdLCBbInQzMzUzIiwgMS4wXSwgWyJ0MzM1NCIsIDEuMF0sIFsidDMzNTUiLCAxLjBdLCBbInQzMzU2IiwgMS4wXSwgWyJ0MzM1NyIsIDEuMF0sIFsidDMzNTgiLCAxLjBdLCBbInQzMzU5IiwgMS4wXSwgWyJ0MzM2MCIsIDEuMF0sIFsidDMzNjEiLCAxLjBdLCBbInQzMzYyIiwgMS4wXSwgWyJ0MzM2MyIsIDEuMF0sIFsidDMzNjQiLCAxLjBdLCBbInQzMzY1IiwgMS4wXSwgWyJ0MzM2NiIsIDEuMF0sIFsidDMzNjciLCAxLjBdLCBbInQzMzY4IiwgMS4wXSwgWyJ0MzM2OSIsIDEuMF0sIFsidDMzNzAiLCAxLjBdLCBbInQzMzcxIiwgMS4wXSwgWyJ0MzM3MiIsIDEuMF0sIFsidDMzNzMiLCAxLjBdLCBbInQzMzc0IiwgMS4wXSwgWyJ0MzM3NSIsIDEuMF0sIFsidDMzNzYiLCAxLjBdLCBbInQzMzc3IiwgMS4wXSwgWyJ0MzM3OCIsIDEuMF0sIFsidDMzNzkiLCAxLjBdLCBbInQzMzgwIiwgMS4wXSwgWyJ0MzM4MSIsIDEuMF0sIFsidDMzODIiLCAxLjBdLCBbInQzMzgzIiwgMS4wXSwgWyJ0MzM4NCIsIDEuMF0sIFsidDMzODUiLCAxLjBdLCBbInQzMzg2IiwgMS4wXSwgWyJ0MzM4NyIsIDEuMF0sIFsidDMzODgiLCAxLjBdLCBbInQzMzg5IiwgMS4wXSwgWyJ0MzM5MCIsIDEuMF0sIFsidDMzOTEiLCAxLjBdLCBbInQzMzkyIiwgMS4wXSwgWyJ0MzM5MyIsIDEuMF0sIFsidDMzOTQiLCAxLjBdLCBbInQzMzk1IiwgMS4wXSwgWyJ0MzM5NiIsIDEuMF0sIFsidDMzOTciLCAxLjBdLCBbInQzMzk4IiwgMS4wXSwgWyJ0MzM5OSIsIDEuMF0sIFsidDM0MDAiLCAxLjBdLCBbInQzNDAxIiwgMS4wXSwgWyJ0MzQwMiIsIDEuMF0sIFsidDM0MDMiLCAxLjBdLCBbInQzNDA0IiwgMS4wXSwgWyJ0MzQwNSIsIDEuMF0sIFsidDM0MDYiLCAxLjBdLCBbInQzNDA3IiwgMS4wXSwgWyJ0MzQwOCIsIDEuMF0sIFsidDM0MDkiLCAxLjBdLCBbInQzNDEwIiwgMS4wXSwgWyJ0MzQxMSIsIDEuMF0sIFsidDM0MTIiLCAxLjBdLCBbInQzNDEzIiwgMS4wXSwgWyJ0MzQxNCIsIDEuMF0sIFsidDM0MTUiLCAxLjBdLCBbInQzNDE2IiwgMS4wXSwgWyJ0MzQxNyIsIDEuMF0sIFsidDM0MTgiLCAxLjBdLCBbInQzNDE5IiwgMS4wXSwgWyJ0MzQyMCIsIDEuMF0sIFsidDM0MjEiLCAxLjBdLCBbInQzNDIyIiwgMS4wXSwgWyJ0MzQyMyIsIDEuMF0sIFsidDM0MjQiLCAxLjBdLCBbInQzNDI1IiwgMS4wXSwgWyJ0MzQyNiIsIDEuMF0sIFsidDM0MjciLCAxLjBdLCBbInQzNDI4IiwgMS4wXSwgWyJ0MzQyOSIsIDEuMF0sIFsidDM0MzAiLCAxLjBdLCBbInQzNDMxIiwgMS4wXSwgWyJ0MzQzMiIsIDEuMF0sIFsidDM0MzMiLCAxLjBdLCBbInQzNDM0IiwgMS4wXSwgWyJ0MzQzNSIsIDEuMF0sIFsidDM0MzYiLCAxLjBdLCBbInQzNDM3IiwgMS4wXSwgWyJ0MzQzOCIsIDEuMF0sIFsidDM0MzkiLCAxLjBdLCBbInQzNDQwIiwgMS4wXSwgWyJ0MzQ0MSIsIDEuMF0sIFsidDM0NDIiLCAxLjBdLCBbInQzNDQzIiwgMS4wXSwgWyJ0MzQ0NCIsIDEuMF0sIFsidDM0NDUiLCAxLjBdLCBbInQzNDQ2IiwgMS4wXSwgWyJ0MzQ0NyIsIDEuMF0sIFsidDM0NDgiLCAxLjBdLCBbInQzNDQ5IiwgMS4wXSwgWyJ0MzQ1MCIsIDEuMF0sIFsidDM0NTEiLCAxLjBdLCBbInQzNDUyIiwgMS4wXSwgWyJ0MzQ1MyIsIDEuMF0sIFsidDM0NTQiLCAxLjBdLCBbInQzNDU1IiwgMS4wXSwgWyJ0MzQ1NiIsIDEuMF0sIFsidDM0NTciLCAxLjBdLCBbInQzNDU4IiwgMS4wXSwgWyJ0MzQ1OSIsIDEuMF0sIFsidDM0NjAiLCAxLjBdLCBbInQzNDYxIiwgMS4wXSwgWyJ0MzQ2MiIsIDEuMF0sIFsidDM0NjMiLCAxLjBdLCBbInQzNDY0IiwgMS4wXSwgWyJ0MzQ2NSIsIDEuMF0sIFsidDM0NjYiLCAxLjBdLCBbInQzNDY3IiwgMS4wXSwgWyJ0MzQ2OCIsIDEuMF0sIFsidDM0NjkiLCAxLjBdLCBbInQzNDcwIiwgMS4wXSwgWyJ0MzQ3MSIsIDEuMF0sIFsidDM0NzIiLCAxLjBdLCBbInQzNDczIiwgMS4wXSwgWyJ0MzQ3NCIsIDEuMF0sIFsidDM0NzUiLCAxLjBdLCBbInQzNDc2IiwgMS4wXSwgWyJ0MzQ3NyIsIDEuMF0sIFsidDM0NzgiLCAxLjBdLCBbInQzNDc5IiwgMS4wXSwgWyJ0MzQ4MCIsIDEuMF0sIFsidDM0ODEiLCAxLjBdLCBbInQzNDgyIiwgMS4wXSwgWyJ0MzQ4MyIsIDEuMF0sIFsidDM0ODQiLCAxLjBdLCBbInQzNDg1IiwgMS4wXSwgWyJ0MzQ4NiIsIDEuMF0sIFsidDM0ODciLCAxLjBdLCBbInQzNDg4IiwgMS4wXSwgWyJ0MzQ4OSIsIDEuMF0sIFsidDM0OTAiLCAxLjBdLCBbInQzNDkxIiwgMS4wXSwgWyJ0MzQ5MiIsIDEuMF0sIFsidDM0OTMiLCAxLjBdLCBbInQzNDk0IiwgMS4wXSwgWyJ0MzQ5NSIsIDEuMF0sIFsidDM0OTYiLCAxLjBdLCBbInQzNDk3IiwgMS4wXSwgWyJ0MzQ5OCIsIDEuMF0sIFsidDM0OTkiLCAxLjBdLCBbInQzNTAwIiwgMS4wXSwgWyJ0MzUwMSIsIDEuMF0sIFsidDM1MDIiLCAxLjBdLCBbInQzNTAzIiwgMS4wXSwgWyJ0MzUwNCIsIDEuMF0sIFsidDM1MDUiLCAxLjBdLCBbInQzNTA2IiwgMS4wXSwgWyJ0MzUwNyIsIDEuMF0sIFsidDM1MDgiLCAxLjBdLCBbInQzNTA5IiwgMS4wXSwgWyJ0MzUxMCIsIDEuMF0sIFsidDM1MTEiLCAxLjBdLCBbInQzNTEyIiwgMS4wXSwgWyJ0MzUxMyIsIDEuMF0sIFsidDM1MTQiLCAxLjBdLCBbInQzNTE1IiwgMS4wXSwgWyJ0MzUxNiIsIDEuMF0sIFsidDM1MTciLCAxLjBdLCBbInQzNTE4IiwgMS4wXSwgWyJ0MzUxOSIsIDEuMF0sIFsidDM1MjAiLCAxLjBdLCBbInQzNTIxIiwgMS4wXSwgWyJ0MzUyMiIsIDEuMF0sIFsidDM1MjMiLCAxLjBdLCBbInQzNTI0IiwgMS4wXSwgWyJ0MzUyNSIsIDEuMF0sIFsidDM1MjYiLCAxLjBdLCBbInQzNTI3IiwgMS4wXSwgWyJ0MzUyOCIsIDEuMF0sIFsidDM1MjkiLCAxLjBdLCBbInQzNTMwIiwgMS4wXSwgWyJ0MzUzMSIsIDEuMF0sIFsidDM1MzIiLCAxLjBdLCBbInQzNTMzIiwgMS4wXSwgWyJ0MzUzNCIsIDEuMF0sIFsidDM1MzUiLCAxLjBdLCBbInQzNTM2IiwgMS4wXSwgWyJ0MzUzNyIsIDEuMF0sIFsidDM1MzgiLCAxLjBdLCBbInQzNTM5IiwgMS4wXSwgWyJ0MzU0MCIsIDEuMF0sIFsidDM1NDEiLCAxLjBdLCBbInQzNTQyIiwgMS4wXSwgWyJ0MzU0MyIsIDEuMF0sIFsidDM1NDQiLCAxLjBdLCBbInQzNTQ1IiwgMS4wXSwgWyJ0MzU0NiIsIDEuMF0sIFsidDM1NDciLCAxLjBdLCBbInQzNTQ4IiwgMS4wXSwgWyJ0MzU0OSIsIDEuMF0sIFsidDM1NTAiLCAxLjBdLCBbInQzNTUxIiwgMS4wXSwgWyJ0MzU1MiIsIDEuMF0sIFsidDM1NTMiLCAxLjBdLCBbInQzNTU0IiwgMS4wXSwgWyJ0MzU1NSIsIDEuMF0sIFsidDM1NTYiLCAxLjBdLCBbInQzNTU3IiwgMS4wXSwgWyJ0MzU1OCIsIDEuMF0sIFsidDM1NTkiLCAxLjBdLCBbInQzNTYwIiwgMS4wXSwgWyJ0MzU2MSIsIDEuMF0sIFsidDM1NjIiLCAxLjBdLCBbInQzNTYzIiwgMS4wXSwgWyJ0MzU2NCIsIDEuMF0sIFsidDM1NjUiLCAxLjBdLCBbInQzNTY2IiwgMS4wXSwgWyJ0MzU2NyIsIDEuMF0sIFsidDM1NjgiLCAxLjBdLCBbInQzNTY5IiwgMS4wXSwgWyJ0MzU3MCIsIDEuMF0sIFsidDM1NzEiLCAxLjBdLCBbInQzNTcyIiwgMS4wXSwgWyJ0MzU3MyIsIDEuMF0sIFsidDM1NzQiLCAxLjBdLCBbInQzNTc1IiwgMS4wXSwgWyJ0MzU3NiIsIDEuMF0sIFsidDM1NzciLCAxLjBdLCBbInQzNTc4IiwgMS4wXSwgWyJ0MzU3OSIsIDEuMF0sIFsidDM1ODAiLCAxLjBdLCBbInQzNTgxIiwgMS4wXSwgWyJ0MzU4MiIsIDEuMF0sIFsidDM1ODMiLCAxLjBdLCBbInQzNTg0IiwgMS4wXSwgWyJ0MzU4NSIsIDEuMF0sIFsidDM1ODYiLCAxLjBdLCBbInQzNTg3IiwgMS4wXSwgWyJ0MzU4OCIsIDEuMF0sIFsidDM1ODkiLCAxLjBdLCBbInQzNTkwIiwgMS4wXSwgWyJ0MzU5MSIsIDEuMF0sIFsidDM1OTIiLCAxLjBdLCBbInQzNTkzIiwgMS4wXSwgWyJ0MzU5NCIsIDEuMF0sIFsidDM1OTUiLCAxLjBdLCBbInQzNTk2IiwgMS4wXSwgWyJ0MzU5NyIsIDEuMF0sIFsidDM1OTgiLCAxLjBdLCBbInQzNTk5IiwgMS4wXSwgWyJ0MzYwMCIsIDEuMF0sIFsidDM2MDEiLCAxLjBdLCBbInQzNjAyIiwgMS4wXSwgWyJ0MzYwMyIsIDEuMF0sIFsidDM2MDQiLCAxLjBdLCBbInQzNjA1IiwgMS4wXSwgWyJ0MzYwNiIsIDEuMF0sIFsidDM2MDciLCAxLjBdLCBbInQzNjA4IiwgMS4wXSwgWyJ0MzYwOSIsIDEuMF0sIFsidDM2MTAiLCAxLjBdLCBbInQzNjExIiwgMS4wXSwgWyJ0MzYxMiIsIDEuMF0sIFsidDM2MTMiLCAxLjBdLCBbInQzNjE0IiwgMS4wXSwgWyJ0MzYxNSIsIDEuMF0sIFsidDM2MTYiLCAxLjBdLCBbInQzNjE3IiwgMS4wXSwgWyJ0MzYxOCIsIDEuMF0sIFsidDM2MTkiLCAxLjBdLCBbInQzNjIwIiwgMS4wXSwgWyJ0MzYyMSIsIDEuMF0sIFsidDM2MjIiLCAxLjBdLCBbInQzNjIzIiwgMS4wXSwgWyJ0MzYyNCIsIDEuMF0sIFsidDM2MjUiLCAxLjBdLCBbInQzNjI2IiwgMS4wXSwgWyJ0MzYyNyIsIDEuMF0sIFsidDM2MjgiLCAxLjBdLCBbInQzNjI5IiwgMS4wXSwgWyJ0MzYzMCIsIDEuMF0sIFsidDM2MzEiLCAxLjBdLCBbInQzNjMyIiwgMS4wXSwgWyJ0MzYzMyIsIDEuMF0sIFsidDM2MzQiLCAxLjBdLCBbInQzNjM1IiwgMS4wXSwgWyJ0MzYzNiIsIDEuMF0sIFsidDM2MzciLCAxLjBdLCBbInQzNjM4IiwgMS4wXSwgWyJ0MzYzOSIsIDEuMF0sIFsidDM2NDAiLCAxLjBdLCBbInQzNjQxIiwgMS4wXSwgWyJ0MzY0MiIsIDEuMF0sIFsidDM2NDMiLCAxLjBdLCBbInQzNjQ0IiwgMS4wXSwgWyJ0MzY0NSIsIDEuMF0sIFsidDM2NDYiLCAxLjBdLCBbInQzNjQ3IiwgMS4wXSwgWyJ0MzY0OCIsIDEuMF0sIFsidDM2NDkiLCAxLjBdLCBbInQzNjUwIiwgMS4wXSwgWyJ0MzY1MSIsIDEuMF0sIFsidDM2NTIiLCAxLjBdLCBbInQzNjUzIiwgMS4wXSwgWyJ0MzY1NCIsIDEuMF0sIFsidDM2NTUiLCAxLjBdLCBbInQzNjU2IiwgMS4wXSwgWyJ0MzY1NyIsIDEuMF0sIFsidDM2NTgiLCAxLjBdLCBbInQzNjU5IiwgMS4wXSwgWyJ0MzY2MCIsIDEuMF0sIFsidDM2NjEiLCAxLjBdLCBbInQzNjYyIiwgMS4wXSwgWyJ0MzY2MyIsIDEuMF0sIFsidDM2NjQiLCAxLjBdLCBbInQzNjY1IiwgMS4wXSwgWyJ0MzY2NiIsIDEuMF0sIFsidDM2NjciLCAxLjBdLCBbInQzNjY4IiwgMS4wXSwgWyJ0MzY2OSIsIDEuMF0sIFsidDM2NzAiLCAxLjBdLCBbInQzNjcxIiwgMS4wXSwgWyJ0MzY3MiIsIDEuMF0sIFsidDM2NzMiLCAxLjBdLCBbInQzNjc0IiwgMS4wXSwgWyJ0MzY3NSIsIDEuMF0sIFsidDM2NzYiLCAxLjBdLCBbInQzNjc3IiwgMS4wXSwgWyJ0MzY3OCIsIDEuMF0sIFsidDM2NzkiLCAxLjBdLCBbInQzNjgwIiwgMS4wXSwgWyJ0MzY4MSIsIDEuMF0sIFsidDM2ODIiLCAxLjBdLCBbInQzNjgzIiwgMS4wXSwgWyJ0MzY4NCIsIDEuMF0sIFsidDM2ODUiLCAxLjBdLCBbInQzNjg2IiwgMS4wXSwgWyJ0MzY4NyIsIDEuMF0sIFsidDM2ODgiLCAxLjBdLCBbInQzNjg5IiwgMS4wXSwgWyJ0MzY5MCIsIDEuMF0sIFsidDM2OTEiLCAxLjBdLCBbInQzNjkyIiwgMS4wXSwgWyJ0MzY5MyIsIDEuMF0sIFsidDM2OTQiLCAxLjBdLCBbInQzNjk1IiwgMS4wXSwgWyJ0MzY5NiIsIDEuMF0sIFsidDM2OTciLCAxLjBdLCBbInQzNjk4IiwgMS4wXSwgWyJ0MzY5OSIsIDEuMF0sIFsidDM3MDAiLCAxLjBdLCBbInQzNzAxIiwgMS4wXSwgWyJ0MzcwMiIsIDEuMF0sIFsidDM3MDMiLCAxLjBdLCBbInQzNzA0IiwgMS4wXSwgWyJ0MzcwNSIsIDEuMF0sIFsidDM3MDYiLCAxLjBdLCBbInQzNzA3IiwgMS4wXSwgWyJ0MzcwOCIsIDEuMF0sIFsidDM3MDkiLCAxLjBdLCBbInQzNzEwIiwgMS4wXSwgWyJ0MzcxMSIsIDEuMF0sIFsidDM3MTIiLCAxLjBdLCBbInQzNzEzIiwgMS4wXSwgWyJ0MzcxNCIsIDEuMF0sIFsidDM3MTUiLCAxLjBdLCBbInQzNzE2IiwgMS4wXSwgWyJ0MzcxNyIsIDEuMF0sIFsidDM3MTgiLCAxLjBdLCBbInQzNzE5IiwgMS4wXSwgWyJ0MzcyMCIsIDEuMF0sIFsidDM3MjEiLCAxLjBdLCBbInQzNzIyIiwgMS4wXSwgWyJ0MzcyMyIsIDEuMF0sIFsidDM3MjQiLCAxLjBdLCBbInQzNzI1IiwgMS4wXSwgWyJ0MzcyNiIsIDEuMF0sIFsidDM3MjciLCAxLjBdLCBbInQzNzI4IiwgMS4wXSwgWyJ0MzcyOSIsIDEuMF0sIFsidDM3MzAiLCAxLjBdLCBbInQzNzMxIiwgMS4wXSwgWyJ0MzczMiIsIDEuMF0sIFsidDM3MzMiLCAxLjBdLCBbInQzNzM0IiwgMS4wXSwgWyJ0MzczNSIsIDEuMF0sIFsidDM3MzYiLCAxLjBdLCBbInQzNzM3IiwgMS4wXSwgWyJ0MzczOCIsIDEuMF0sIFsidDM3MzkiLCAxLjBdLCBbInQzNzQwIiwgMS4wXSwgWyJ0Mzc0MSIsIDEuMF0sIFsidDM3NDIiLCAxLjBdLCBbInQzNzQzIiwgMS4wXSwgWyJ0Mzc0NCIsIDEuMF0sIFsidDM3NDUiLCAxLjBdLCBbInQzNzQ2IiwgMS4wXSwgWyJ0Mzc0NyIsIDEuMF0sIFsidDM3NDgiLCAxLjBdLCBbInQzNzQ5IiwgMS4wXSwgWyJ0Mzc1MCIsIDEuMF0sIFsidDM3NTEiLCAxLjBdLCBbInQzNzUyIiwgMS4wXSwgWyJ0Mzc1MyIsIDEuMF0sIFsidDM3NTQiLCAxLjBdLCBbInQzNzU1IiwgMS4wXSwgWyJ0Mzc1NiIsIDEuMF0sIFsidDM3NTciLCAxLjBdLCBbInQzNzU4IiwgMS4wXSwgWyJ0Mzc1OSIsIDEuMF0sIFsidDM3NjAiLCAxLjBdLCBbInQzNzYxIiwgMS4wXSwgWyJ0Mzc2MiIsIDEuMF0sIFsidDM3NjMiLCAxLjBdLCBbInQzNzY0IiwgMS4wXSwgWyJ0Mzc2NSIsIDEuMF0sIFsidDM3NjYiLCAxLjBdLCBbInQzNzY3IiwgMS4wXSwgWyJ0Mzc2OCIsIDEuMF0sIFsidDM3NjkiLCAxLjBdLCBbInQzNzcwIiwgMS4wXSwgWyJ0Mzc3MSIsIDEuMF0sIFsidDM3NzIiLCAxLjBdLCBbInQzNzczIiwgMS4wXSwgWyJ0Mzc3NCIsIDEuMF0sIFsidDM3NzUiLCAxLjBdLCBbInQzNzc2IiwgMS4wXSwgWyJ0Mzc3NyIsIDEuMF0sIFsidDM3NzgiLCAxLjBdLCBbInQzNzc5IiwgMS4wXSwgWyJ0Mzc4MCIsIDEuMF0sIFsidDM3ODEiLCAxLjBdLCBbInQzNzgyIiwgMS4wXSwgWyJ0Mzc4MyIsIDEuMF0sIFsidDM3ODQiLCAxLjBdLCBbInQzNzg1IiwgMS4wXSwgWyJ0Mzc4NiIsIDEuMF0sIFsidDM3ODciLCAxLjBdLCBbInQzNzg4IiwgMS4wXSwgWyJ0Mzc4OSIsIDEuMF0sIFsidDM3OTAiLCAxLjBdLCBbInQzNzkxIiwgMS4wXSwgWyJ0Mzc5MiIsIDEuMF0sIFsidDM3OTMiLCAxLjBdLCBbInQzNzk0IiwgMS4wXSwgWyJ0Mzc5NSIsIDEuMF0sIFsidDM3OTYiLCAxLjBdLCBbInQzNzk3IiwgMS4wXSwgWyJ0Mzc5OCIsIDEuMF0sIFsidDM3OTkiLCAxLjBdLCBbInQzODAwIiwgMS4wXSwgWyJ0MzgwMSIsIDEuMF0sIFsidDM4MDIiLCAxLjBdLCBbInQzODAzIiwgMS4wXSwgWyJ0MzgwNCIsIDEuMF0sIFsidDM4MDUiLCAxLjBdLCBbInQzODA2IiwgMS4wXSwgWyJ0MzgwNyIsIDEuMF0sIFsidDM4MDgiLCAxLjBdLCBbInQzODA5IiwgMS4wXSwgWyJ0MzgxMCIsIDEuMF0sIFsidDM4MTEiLCAxLjBdLCBbInQzODEyIiwgMS4wXSwgWyJ0MzgxMyIsIDEuMF0sIFsidDM4MTQiLCAxLjBdLCBbInQzODE1IiwgMS4wXSwgWyJ0MzgxNiIsIDEuMF0sIFsidDM4MTciLCAxLjBdLCBbInQzODE4IiwgMS4wXSwgWyJ0MzgxOSIsIDEuMF0sIFsidDM4MjAiLCAxLjBdLCBbInQzODIxIiwgMS4wXSwgWyJ0MzgyMiIsIDEuMF0sIFsidDM4MjMiLCAxLjBdLCBbInQzODI0IiwgMS4wXSwgWyJ0MzgyNSIsIDEuMF0sIFsidDM4MjYiLCAxLjBdLCBbInQzODI3IiwgMS4wXSwgWyJ0MzgyOCIsIDEuMF0sIFsidDM4MjkiLCAxLjBdLCBbInQzODMwIiwgMS4wXSwgWyJ0MzgzMSIsIDEuMF0sIFsidDM4MzIiLCAxLjBdLCBbInQzODMzIiwgMS4wXSwgWyJ0MzgzNCIsIDEuMF0sIFsidDM4MzUiLCAxLjBdLCBbInQzODM2IiwgMS4wXSwgWyJ0MzgzNyIsIDEuMF0sIFsidDM4MzgiLCAxLjBdLCBbInQzODM5IiwgMS4wXSwgWyJ0Mzg0MCIsIDEuMF0sIFsidDM4NDEiLCAxLjBdLCBbInQzODQyIiwgMS4wXSwgWyJ0Mzg0MyIsIDEuMF0sIFsidDM4NDQiLCAxLjBdLCBbInQzODQ1IiwgMS4wXSwgWyJ0Mzg0NiIsIDEuMF0sIFsidDM4NDciLCAxLjBdLCBbInQzODQ4IiwgMS4wXSwgWyJ0Mzg0OSIsIDEuMF0sIFsidDM4NTAiLCAxLjBdLCBbInQzODUxIiwgMS4wXSwgWyJ0Mzg1MiIsIDEuMF0sIFsidDM4NTMiLCAxLjBdLCBbInQzODU0IiwgMS4wXSwgWyJ0Mzg1NSIsIDEuMF0sIFsidDM4NTYiLCAxLjBdLCBbInQzODU3IiwgMS4wXSwgWyJ0Mzg1OCIsIDEuMF0sIFsidDM4NTkiLCAxLjBdLCBbInQzODYwIiwgMS4wXSwgWyJ0Mzg2MSIsIDEuMF0sIFsidDM4NjIiLCAxLjBdLCBbInQzODYzIiwgMS4wXSwgWyJ0Mzg2NCIsIDEuMF0sIFsidDM4NjUiLCAxLjBdLCBbInQzODY2IiwgMS4wXSwgWyJ0Mzg2NyIsIDEuMF0sIFsidDM4NjgiLCAxLjBdLCBbInQzODY5IiwgMS4wXSwgWyJ0Mzg3MCIsIDEuMF0sIFsidDM4NzEiLCAxLjBdLCBbInQzODcyIiwgMS4wXSwgWyJ0Mzg3MyIsIDEuMF0sIFsidDM4NzQiLCAxLjBdLCBbInQzODc1IiwgMS4wXSwgWyJ0Mzg3NiIsIDEuMF0sIFsidDM4NzciLCAxLjBdLCBbInQzODc4IiwgMS4wXSwgWyJ0Mzg3OSIsIDEuMF0sIFsidDM4ODAiLCAxLjBdLCBbInQzODgxIiwgMS4wXSwgWyJ0Mzg4MiIsIDEuMF0sIFsidDM4ODMiLCAxLjBdLCBbInQzODg0IiwgMS4wXSwgWyJ0Mzg4NSIsIDEuMF0sIFsidDM4ODYiLCAxLjBdLCBbInQzODg3IiwgMS4wXSwgWyJ0Mzg4OCIsIDEuMF0sIFsidDM4ODkiLCAxLjBdLCBbInQzODkwIiwgMS4wXSwgWyJ0Mzg5MSIsIDEuMF0sIFsidDM4OTIiLCAxLjBdLCBbInQzODkzIiwgMS4wXSwgWyJ0Mzg5NCIsIDEuMF0sIFsidDM4OTUiLCAxLjBdLCBbInQzODk2IiwgMS4wXSwgWyJ0Mzg5NyIsIDEuMF0sIFsidDM4OTgiLCAxLjBdLCBbInQzODk5IiwgMS4wXSwgWyJ0MzkwMCIsIDEuMF0sIFsidDM5MDEiLCAxLjBdLCBbInQzOTAyIiwgMS4wXSwgWyJ0MzkwMyIsIDEuMF0sIFsidDM5MDQiLCAxLjBdLCBbInQzOTA1IiwgMS4wXSwgWyJ0MzkwNiIsIDEuMF0sIFsidDM5MDciLCAxLjBdLCBbInQzOTA4IiwgMS4wXSwgWyJ0MzkwOSIsIDEuMF0sIFsidDM5MTAiLCAxLjBdLCBbInQzOTExIiwgMS4wXSwgWyJ0MzkxMiIsIDEuMF0sIFsidDM5MTMiLCAxLjBdLCBbInQzOTE0IiwgMS4wXSwgWyJ0MzkxNSIsIDEuMF0sIFsidDM5MTYiLCAxLjBdLCBbInQzOTE3IiwgMS4wXSwgWyJ0MzkxOCIsIDEuMF0sIFsidDM5MTkiLCAxLjBdLCBbInQzOTIwIiwgMS4wXSwgWyJ0MzkyMSIsIDEuMF0sIFsidDM5MjIiLCAxLjBdLCBbInQzOTIzIiwgMS4wXSwgWyJ0MzkyNCIsIDEuMF0sIFsidDM5MjUiLCAxLjBdLCBbInQzOTI2IiwgMS4wXSwgWyJ0MzkyNyIsIDEuMF0sIFsidDM5MjgiLCAxLjBdLCBbInQzOTI5IiwgMS4wXSwgWyJ0MzkzMCIsIDEuMF0sIFsidDM5MzEiLCAxLjBdLCBbInQzOTMyIiwgMS4wXSwgWyJ0MzkzMyIsIDEuMF0sIFsidDM5MzQiLCAxLjBdLCBbInQzOTM1IiwgMS4wXSwgWyJ0MzkzNiIsIDEuMF0sIFsidDM5MzciLCAxLjBdLCBbInQzOTM4IiwgMS4wXSwgWyJ0MzkzOSIsIDEuMF0sIFsidDM5NDAiLCAxLjBdLCBbInQzOTQxIiwgMS4wXSwgWyJ0Mzk0MiIsIDEuMF0sIFsidDM5NDMiLCAxLjBdLCBbInQzOTQ0IiwgMS4wXSwgWyJ0Mzk0NSIsIDEuMF0sIFsidDM5NDYiLCAxLjBdLCBbInQzOTQ3IiwgMS4wXSwgWyJ0Mzk0OCIsIDEuMF0sIFsidDM5NDkiLCAxLjBdLCBbInQzOTUwIiwgMS4wXSwgWyJ0Mzk1MSIsIDEuMF0sIFsidDM5NTIiLCAxLjBdLCBbInQzOTUzIiwgMS4wXSwgWyJ0Mzk1NCIsIDEuMF0sIFsidDM5NTUiLCAxLjBdLCBbInQzOTU2IiwgMS4wXSwgWyJ0Mzk1NyIsIDEuMF0sIFsidDM5NTgiLCAxLjBdLCBbInQzOTU5IiwgMS4wXSwgWyJ0Mzk2MCIsIDEuMF0sIFsidDM5NjEiLCAxLjBdLCBbInQzOTYyIiwgMS4wXSwgWyJ0Mzk2MyIsIDEuMF0sIFsidDM5NjQiLCAxLjBdLCBbInQzOTY1IiwgMS4wXSwgWyJ0Mzk2NiIsIDEuMF0sIFsidDM5NjciLCAxLjBdLCBbInQzOTY4IiwgMS4wXSwgWyJ0Mzk2OSIsIDEuMF0sIFsidDM5NzAiLCAxLjBdLCBbInQzOTcxIiwgMS4wXSwgWyJ0Mzk3MiIsIDEuMF0sIFsidDM5NzMiLCAxLjBdLCBbInQzOTc0IiwgMS4wXSwgWyJ0Mzk3NSIsIDEuMF0sIFsidDM5NzYiLCAxLjBdLCBbInQzOTc3IiwgMS4wXSwgWyJ0Mzk3OCIsIDEuMF0sIFsidDM5NzkiLCAxLjBdLCBbInQzOTgwIiwgMS4wXSwgWyJ0Mzk4MSIsIDEuMF0sIFsidDM5ODIiLCAxLjBdLCBbInQzOTgzIiwgMS4wXSwgWyJ0Mzk4NCIsIDEuMF0sIFsidDM5ODUiLCAxLjBdLCBbInQzOTg2IiwgMS4wXSwgWyJ0Mzk4NyIsIDEuMF0sIFsidDM5ODgiLCAxLjBdLCBbInQzOTg5IiwgMS4wXSwgWyJ0Mzk5MCIsIDEuMF0sIFsidDM5OTEiLCAxLjBdLCBbInQzOTkyIiwgMS4wXSwgWyJ0Mzk5MyIsIDEuMF0sIFsidDM5OTQiLCAxLjBdLCBbInQzOTk1IiwgMS4wXSwgWyJ0Mzk5NiIsIDEuMF0sIFsidDM5OTciLCAxLjBdLCBbInQzOTk4IiwgMS4wXSwgWyJ0Mzk5OSIsIDEuMF0sIFsidDQwMDAiLCAxLjBdLCBbInQ0MDAxIiwgMS4wXSwgWyJ0NDAwMiIsIDEuMF0sIFsidDQwMDMiLCAxLjBdLCBbInQ0MDA0IiwgMS4wXSwgWyJ0NDAwNSIsIDEuMF0sIFsidDQwMDYiLCAxLjBdLCBbInQ0MDA3IiwgMS4wXSwgWyJ0NDAwOCIsIDEuMF0sIFsidDQwMDkiLCAxLjBdLCBbInQ0MDEwIiwgMS4wXSwgWyJ0NDAxMSIsIDEuMF0sIFsidDQwMTIiLCAxLjBdLCBbInQ0MDEzIiwgMS4wXSwgWyJ0NDAxNCIsIDEuMF0sIFsidDQwMTUiLCAxLjBdLCBbInQ0MDE2IiwgMS4wXSwgWyJ0NDAxNyIsIDEuMF0sIFsidDQwMTgiLCAxLjBdLCBbInQ0MDE5IiwgMS4wXSwgWyJ0NDAyMCIsIDEuMF0sIFsidDQwMjEiLCAxLjBdLCBbInQ0MDIyIiwgMS4wXSwgWyJ0NDAyMyIsIDEuMF0sIFsidDQwMjQiLCAxLjBdLCBbInQ0MDI1IiwgMS4wXSwgWyJ0NDAyNiIsIDEuMF0sIFsidDQwMjciLCAxLjBdLCBbInQ0MDI4IiwgMS4wXSwgWyJ0NDAyOSIsIDEuMF0sIFsidDQwMzAiLCAxLjBdLCBbInQ0MDMxIiwgMS4wXSwgWyJ0NDAzMiIsIDEuMF0sIFsidDQwMzMiLCAxLjBdLCBbInQ0MDM0IiwgMS4wXSwgWyJ0NDAzNSIsIDEuMF0sIFsidDQwMzYiLCAxLjBdLCBbInQ0MDM3IiwgMS4wXSwgWyJ0NDAzOCIsIDEuMF0sIFsidDQwMzkiLCAxLjBdLCBbInQ0MDQwIiwgMS4wXSwgWyJ0NDA0MSIsIDEuMF0sIFsidDQwNDIiLCAxLjBdLCBbInQ0MDQzIiwgMS4wXSwgWyJ0NDA0NCIsIDEuMF0sIFsidDQwNDUiLCAxLjBdLCBbInQ0MDQ2IiwgMS4wXSwgWyJ0NDA0NyIsIDEuMF0sIFsidDQwNDgiLCAxLjBdLCBbInQ0MDQ5IiwgMS4wXSwgWyJ0NDA1MCIsIDEuMF0sIFsidDQwNTEiLCAxLjBdLCBbInQ0MDUyIiwgMS4wXSwgWyJ0NDA1MyIsIDEuMF0sIFsidDQwNTQiLCAxLjBdLCBbInQ0MDU1IiwgMS4wXSwgWyJ0NDA1NiIsIDEuMF0sIFsidDQwNTciLCAxLjBdLCBbInQ0MDU4IiwgMS4wXSwgWyJ0NDA1OSIsIDEuMF0sIFsidDQwNjAiLCAxLjBdLCBbInQ0MDYxIiwgMS4wXSwgWyJ0NDA2MiIsIDEuMF0sIFsidDQwNjMiLCAxLjBdLCBbInQ0MDY0IiwgMS4wXSwgWyJ0NDA2NSIsIDEuMF0sIFsidDQwNjYiLCAxLjBdLCBbInQ0MDY3IiwgMS4wXSwgWyJ0NDA2OCIsIDEuMF0sIFsidDQwNjkiLCAxLjBdLCBbInQ0MDcwIiwgMS4wXSwgWyJ0NDA3MSIsIDEuMF0sIFsidDQwNzIiLCAxLjBdLCBbInQ0MDczIiwgMS4wXSwgWyJ0NDA3NCIsIDEuMF0sIFsidDQwNzUiLCAxLjBdLCBbInQ0MDc2IiwgMS4wXSwgWyJ0NDA3NyIsIDEuMF0sIFsidDQwNzgiLCAxLjBdLCBbInQ0MDc5IiwgMS4wXSwgWyJ0NDA4MCIsIDEuMF0sIFsidDQwODEiLCAxLjBdLCBbInQ0MDgyIiwgMS4wXSwgWyJ0NDA4MyIsIDEuMF0sIFsidDQwODQiLCAxLjBdLCBbInQ0MDg1IiwgMS4wXSwgWyJ0NDA4NiIsIDEuMF0sIFsidDQwODciLCAxLjBdLCBbInQ0MDg4IiwgMS4wXSwgWyJ0NDA4OSIsIDEuMF0sIFsidDQwOTAiLCAxLjBdLCBbInQ0MDkxIiwgMS4wXSwgWyJ0NDA5MiIsIDEuMF0sIFsidDQwOTMiLCAxLjBdLCBbInQ0MDk0IiwgMS4wXSwgWyJ0NDA5NSIsIDEuMF0sIFsidDQwOTYiLCAxLjBdLCBbInQ0MDk3IiwgMS4wXSwgWyJ0NDA5OCIsIDEuMF0sIFsidDQwOTkiLCAxLjBdLCBbInQ0MTAwIiwgMS4wXSwgWyJ0NDEwMSIsIDEuMF0sIFsidDQxMDIiLCAxLjBdLCBbInQ0MTAzIiwgMS4wXSwgWyJ0NDEwNCIsIDEuMF0sIFsidDQxMDUiLCAxLjBdLCBbInQ0MTA2IiwgMS4wXSwgWyJ0NDEwNyIsIDEuMF0sIFsidDQxMDgiLCAxLjBdLCBbInQ0MTA5IiwgMS4wXSwgWyJ0NDExMCIsIDEuMF0sIFsidDQxMTEiLCAxLjBdLCBbInQ0MTEyIiwgMS4wXSwgWyJ0NDExMyIsIDEuMF0sIFsidDQxMTQiLCAxLjBdLCBbInQ0MTE1IiwgMS4wXSwgWyJ0NDExNiIsIDEuMF0sIFsidDQxMTciLCAxLjBdLCBbInQ0MTE4IiwgMS4wXSwgWyJ0NDExOSIsIDEuMF0sIFsidDQxMjAiLCAxLjBdLCBbInQ0MTIxIiwgMS4wXSwgWyJ0NDEyMiIsIDEuMF0sIFsidDQxMjMiLCAxLjBdLCBbInQ0MTI0IiwgMS4wXSwgWyJ0NDEyNSIsIDEuMF0sIFsidDQxMjYiLCAxLjBdLCBbInQ0MTI3IiwgMS4wXSwgWyJ0NDEyOCIsIDEuMF0sIFsidDQxMjkiLCAxLjBdLCBbInQ0MTMwIiwgMS4wXSwgWyJ0NDEzMSIsIDEuMF0sIFsidDQxMzIiLCAxLjBdLCBbInQ0MTMzIiwgMS4wXSwgWyJ0NDEzNCIsIDEuMF0sIFsidDQxMzUiLCAxLjBdLCBbInQ0MTM2IiwgMS4wXSwgWyJ0NDEzNyIsIDEuMF0sIFsidDQxMzgiLCAxLjBdLCBbInQ0MTM5IiwgMS4wXSwgWyJ0NDE0MCIsIDEuMF0sIFsidDQxNDEiLCAxLjBdLCBbInQ0MTQyIiwgMS4wXSwgWyJ0NDE0MyIsIDEuMF0sIFsidDQxNDQiLCAxLjBdLCBbInQ0MTQ1IiwgMS4wXSwgWyJ0NDE0NiIsIDEuMF0sIFsidDQxNDciLCAxLjBdLCBbInQ0MTQ4IiwgMS4wXSwgWyJ0NDE0OSIsIDEuMF0sIFsidDQxNTAiLCAxLjBdLCBbInQ0MTUxIiwgMS4wXSwgWyJ0NDE1MiIsIDEuMF0sIFsidDQxNTMiLCAxLjBdLCBbInQ0MTU0IiwgMS4wXSwgWyJ0NDE1NSIsIDEuMF0sIFsidDQxNTYiLCAxLjBdLCBbInQ0MTU3IiwgMS4wXSwgWyJ0NDE1OCIsIDEuMF0sIFsidDQxNTkiLCAxLjBdLCBbInQ0MTYwIiwgMS4wXSwgWyJ0NDE2MSIsIDEuMF0sIFsidDQxNjIiLCAxLjBdLCBbInQ0MTYzIiwgMS4wXSwgWyJ0NDE2NCIsIDEuMF0sIFsidDQxNjUiLCAxLjBdLCBbInQ0MTY2IiwgMS4wXSwgWyJ0NDE2NyIsIDEuMF0sIFsidDQxNjgiLCAxLjBdLCBbInQ0MTY5IiwgMS4wXSwgWyJ0NDE3MCIsIDEuMF0sIFsidDQxNzEiLCAxLjBdLCBbInQ0MTcyIiwgMS4wXSwgWyJ0NDE3MyIsIDEuMF0sIFsidDQxNzQiLCAxLjBdLCBbInQ0MTc1IiwgMS4wXSwgWyJ0NDE3NiIsIDEuMF0sIFsidDQxNzciLCAxLjBdLCBbInQ0MTc4IiwgMS4wXSwgWyJ0NDE3OSIsIDEuMF0sIFsidDQxODAiLCAxLjBdLCBbInQ0MTgxIiwgMS4wXSwgWyJ0NDE4MiIsIDEuMF0sIFsidDQxODMiLCAxLjBdLCBbInQ0MTg0IiwgMS4wXSwgWyJ0NDE4NSIsIDEuMF0sIFsidDQxODYiLCAxLjBdLCBbInQ0MTg3IiwgMS4wXSwgWyJ0NDE4OCIsIDEuMF0sIFsidDQxODkiLCAxLjBdLCBbInQ0MTkwIiwgMS4wXSwgWyJ0NDE5MSIsIDEuMF0sIFsidDQxOTIiLCAxLjBdLCBbInQ0MTkzIiwgMS4wXSwgWyJ0NDE5NCIsIDEuMF0sIFsidDQxOTUiLCAxLjBdLCBbInQ0MTk2IiwgMS4wXSwgWyJ0NDE5NyIsIDEuMF0sIFsidDQxOTgiLCAxLjBdLCBbInQ0MTk5IiwgMS4wXSwgWyJ0NDIwMCIsIDEuMF0sIFsidDQyMDEiLCAxLjBdLCBbInQ0MjAyIiwgMS4wXSwgWyJ0NDIwMyIsIDEuMF0sIFsidDQyMDQiLCAxLjBdLCBbInQ0MjA1IiwgMS4wXSwgWyJ0NDIwNiIsIDEuMF0sIFsidDQyMDciLCAxLjBdLCBbInQ0MjA4IiwgMS4wXSwgWyJ0NDIwOSIsIDEuMF0sIFsidDQyMTAiLCAxLjBdLCBbInQ0MjExIiwgMS4wXSwgWyJ0NDIxMiIsIDEuMF0sIFsidDQyMTMiLCAxLjBdLCBbInQ0MjE0IiwgMS4wXSwgWyJ0NDIxNSIsIDEuMF0sIFsidDQyMTYiLCAxLjBdLCBbInQ0MjE3IiwgMS4wXSwgWyJ0NDIxOCIsIDEuMF0sIFsidDQyMTkiLCAxLjBdLCBbInQ0MjIwIiwgMS4wXSwgWyJ0NDIyMSIsIDEuMF0sIFsidDQyMjIiLCAxLjBdLCBbInQ0MjIzIiwgMS4wXSwgWyJ0NDIyNCIsIDEuMF0sIFsidDQyMjUiLCAxLjBdLCBbInQ0MjI2IiwgMS4wXSwgWyJ0NDIyNyIsIDEuMF0sIFsidDQyMjgiLCAxLjBdLCBbInQ0MjI5IiwgMS4wXSwgWyJ0NDIzMCIsIDEuMF0sIFsidDQyMzEiLCAxLjBdLCBbInQ0MjMyIiwgMS4wXSwgWyJ0NDIzMyIsIDEuMF0sIFsidDQyMzQiLCAxLjBdLCBbInQ0MjM1IiwgMS4wXSwgWyJ0NDIzNiIsIDEuMF0sIFsidDQyMzciLCAxLjBdLCBbInQ0MjM4IiwgMS4wXSwgWyJ0NDIzOSIsIDEuMF0sIFsidDQyNDAiLCAxLjBdLCBbInQ0MjQxIiwgMS4wXSwgWyJ0NDI0MiIsIDEuMF0sIFsidDQyNDMiLCAxLjBdLCBbInQ0MjQ0IiwgMS4wXSwgWyJ0NDI0NSIsIDEuMF0sIFsidDQyNDYiLCAxLjBdLCBbInQ0MjQ3IiwgMS4wXSwgWyJ0NDI0OCIsIDEuMF0sIFsidDQyNDkiLCAxLjBdLCBbInQ0MjUwIiwgMS4wXSwgWyJ0NDI1MSIsIDEuMF0sIFsidDQyNTIiLCAxLjBdLCBbInQ0MjUzIiwgMS4wXSwgWyJ0NDI1NCIsIDEuMF0sIFsidDQyNTUiLCAxLjBdLCBbInQ0MjU2IiwgMS4wXSwgWyJ0NDI1NyIsIDEuMF0sIFsidDQyNTgiLCAxLjBdLCBbInQ0MjU5IiwgMS4wXSwgWyJ0NDI2MCIsIDEuMF0sIFsidDQyNjEiLCAxLjBdLCBbInQ0MjYyIiwgMS4wXSwgWyJ0NDI2MyIsIDEuMF0sIFsidDQyNjQiLCAxLjBdLCBbInQ0MjY1IiwgMS4wXSwgWyJ0NDI2NiIsIDEuMF0sIFsidDQyNjciLCAxLjBdLCBbInQ0MjY4IiwgMS4wXSwgWyJ0NDI2OSIsIDEuMF0sIFsidDQyNzAiLCAxLjBdLCBbInQ0MjcxIiwgMS4wXSwgWyJ0NDI3MiIsIDEuMF0sIFsidDQyNzMiLCAxLjBdLCBbInQ0Mjc0IiwgMS4wXSwgWyJ0NDI3NSIsIDEuMF0sIFsidDQyNzYiLCAxLjBdLCBbInQ0Mjc3IiwgMS4wXSwgWyJ0NDI3OCIsIDEuMF0sIFsidDQyNzkiLCAxLjBdLCBbInQ0MjgwIiwgMS4wXSwgWyJ0NDI4MSIsIDEuMF0sIFsidDQyODIiLCAxLjBdLCBbInQ0MjgzIiwgMS4wXSwgWyJ0NDI4NCIsIDEuMF0sIFsidDQyODUiLCAxLjBdLCBbInQ0Mjg2IiwgMS4wXSwgWyJ0NDI4NyIsIDEuMF0sIFsidDQyODgiLCAxLjBdLCBbInQ0Mjg5IiwgMS4wXSwgWyJ0NDI5MCIsIDEuMF0sIFsidDQyOTEiLCAxLjBdLCBbInQ0MjkyIiwgMS4wXSwgWyJ0NDI5MyIsIDEuMF0sIFsidDQyOTQiLCAxLjBdLCBbInQ0Mjk1IiwgMS4wXSwgWyJ0NDI5NiIsIDEuMF0sIFsidDQyOTciLCAxLjBdLCBbInQ0Mjk4IiwgMS4wXSwgWyJ0NDI5OSIsIDEuMF0sIFsidDQzMDAiLCAxLjBdLCBbInQ0MzAxIiwgMS4wXSwgWyJ0NDMwMiIsIDEuMF0sIFsidDQzMDMiLCAxLjBdLCBbInQ0MzA0IiwgMS4wXSwgWyJ0NDMwNSIsIDEuMF0sIFsidDQzMDYiLCAxLjBdLCBbInQ0MzA3IiwgMS4wXSwgWyJ0NDMwOCIsIDEuMF0sIFsidDQzMDkiLCAxLjBdLCBbInQ0MzEwIiwgMS4wXSwgWyJ0NDMxMSIsIDEuMF0sIFsidDQzMTIiLCAxLjBdLCBbInQ0MzEzIiwgMS4wXSwgWyJ0NDMxNCIsIDEuMF0sIFsidDQzMTUiLCAxLjBdLCBbInQ0MzE2IiwgMS4wXSwgWyJ0NDMxNyIsIDEuMF0sIFsidDQzMTgiLCAxLjBdLCBbInQ0MzE5IiwgMS4wXSwgWyJ0NDMyMCIsIDEuMF0sIFsidDQzMjEiLCAxLjBdLCBbInQ0MzIyIiwgMS4wXSwgWyJ0NDMyMyIsIDEuMF0sIFsidDQzMjQiLCAxLjBdLCBbInQ0MzI1IiwgMS4wXSwgWyJ0NDMyNiIsIDEuMF0sIFsidDQzMjciLCAxLjBdLCBbInQ0MzI4IiwgMS4wXSwgWyJ0NDMyOSIsIDEuMF0sIFsidDQzMzAiLCAxLjBdLCBbInQ0MzMxIiwgMS4wXSwgWyJ0NDMzMiIsIDEuMF0sIFsidDQzMzMiLCAxLjBdLCBbInQ0MzM0IiwgMS4wXSwgWyJ0NDMzNSIsIDEuMF0sIFsidDQzMzYiLCAxLjBdLCBbInQ0MzM3IiwgMS4wXSwgWyJ0NDMzOCIsIDEuMF0sIFsidDQzMzkiLCAxLjBdLCBbInQ0MzQwIiwgMS4wXSwgWyJ0NDM0MSIsIDEuMF0sIFsidDQzNDIiLCAxLjBdLCBbInQ0MzQzIiwgMS4wXSwgWyJ0NDM0NCIsIDEuMF0sIFsidDQzNDUiLCAxLjBdLCBbInQ0MzQ2IiwgMS4wXSwgWyJ0NDM0NyIsIDEuMF0sIFsidDQzNDgiLCAxLjBdLCBbInQ0MzQ5IiwgMS4wXSwgWyJ0NDM1MCIsIDEuMF0sIFsidDQzNTEiLCAxLjBdLCBbInQ0MzUyIiwgMS4wXSwgWyJ0NDM1MyIsIDEuMF0sIFsidDQzNTQiLCAxLjBdLCBbInQ0MzU1IiwgMS4wXSwgWyJ0NDM1NiIsIDEuMF0sIFsidDQzNTciLCAxLjBdLCBbInQ0MzU4IiwgMS4wXSwgWyJ0NDM1OSIsIDEuMF0sIFsidDQzNjAiLCAxLjBdLCBbInQ0MzYxIiwgMS4wXSwgWyJ0NDM2MiIsIDEuMF0sIFsidDQzNjMiLCAxLjBdLCBbInQ0MzY0IiwgMS4wXSwgWyJ0NDM2NSIsIDEuMF0sIFsidDQzNjYiLCAxLjBdLCBbInQ0MzY3IiwgMS4wXSwgWyJ0NDM2OCIsIDEuMF0sIFsidDQzNjkiLCAxLjBdLCBbInQ0MzcwIiwgMS4wXSwgWyJ0NDM3MSIsIDEuMF0sIFsidDQzNzIiLCAxLjBdLCBbInQ0MzczIiwgMS4wXSwgWyJ0NDM3NCIsIDEuMF0sIFsidDQzNzUiLCAxLjBdLCBbInQ0Mzc2IiwgMS4wXSwgWyJ0NDM3NyIsIDEuMF0sIFsidDQzNzgiLCAxLjBdLCBbInQ0Mzc5IiwgMS4wXSwgWyJ0NDM4MCIsIDEuMF0sIFsidDQzODEiLCAxLjBdLCBbInQ0MzgyIiwgMS4wXSwgWyJ0NDM4MyIsIDEuMF0sIFsidDQzODQiLCAxLjBdLCBbInQ0Mzg1IiwgMS4wXSwgWyJ0NDM4NiIsIDEuMF0sIFsidDQzODciLCAxLjBdLCBbInQ0Mzg4IiwgMS4wXSwgWyJ0NDM4OSIsIDEuMF0sIFsidDQzOTAiLCAxLjBdLCBbInQ0MzkxIiwgMS4wXSwgWyJ0NDM5MiIsIDEuMF0sIFsidDQzOTMiLCAxLjBdLCBbInQ0Mzk0IiwgMS4wXSwgWyJ0NDM5NSIsIDEuMF0sIFsidDQzOTYiLCAxLjBdLCBbInQ0Mzk3IiwgMS4wXSwgWyJ0NDM5OCIsIDEuMF0sIFsidDQzOTkiLCAxLjBdLCBbInQ0NDAwIiwgMS4wXSwgWyJ0NDQwMSIsIDEuMF0sIFsidDQ0MDIiLCAxLjBdLCBbInQ0NDAzIiwgMS4wXSwgWyJ0NDQwNCIsIDEuMF0sIFsidDQ0MDUiLCAxLjBdLCBbInQ0NDA2IiwgMS4wXSwgWyJ0NDQwNyIsIDEuMF0sIFsidDQ0MDgiLCAxLjBdLCBbInQ0NDA5IiwgMS4wXSwgWyJ0NDQxMCIsIDEuMF0sIFsidDQ0MTEiLCAxLjBdLCBbInQ0NDEyIiwgMS4wXSwgWyJ0NDQxMyIsIDEuMF0sIFsidDQ0MTQiLCAxLjBdLCBbInQ0NDE1IiwgMS4wXSwgWyJ0NDQxNiIsIDEuMF0sIFsidDQ0MTciLCAxLjBdLCBbInQ0NDE4IiwgMS4wXSwgWyJ0NDQxOSIsIDEuMF0sIFsidDQ0MjAiLCAxLjBdLCBbInQ0NDIxIiwgMS4wXSwgWyJ0NDQyMiIsIDEuMF0sIFsidDQ0MjMiLCAxLjBdLCBbInQ0NDI0IiwgMS4wXSwgWyJ0NDQyNSIsIDEuMF0sIFsidDQ0MjYiLCAxLjBdLCBbInQ0NDI3IiwgMS4wXSwgWyJ0NDQyOCIsIDEuMF0sIFsidDQ0MjkiLCAxLjBdLCBbInQ0NDMwIiwgMS4wXSwgWyJ0NDQzMSIsIDEuMF0sIFsidDQ0MzIiLCAxLjBdLCBbInQ0NDMzIiwgMS4wXSwgWyJ0NDQzNCIsIDEuMF0sIFsidDQ0MzUiLCAxLjBdLCBbInQ0NDM2IiwgMS4wXSwgWyJ0NDQzNyIsIDEuMF0sIFsidDQ0MzgiLCAxLjBdLCBbInQ0NDM5IiwgMS4wXSwgWyJ0NDQ0MCIsIDEuMF0sIFsidDQ0NDEiLCAxLjBdLCBbInQ0NDQyIiwgMS4wXSwgWyJ0NDQ0MyIsIDEuMF0sIFsidDQ0NDQiLCAxLjBdLCBbInQ0NDQ1IiwgMS4wXSwgWyJ0NDQ0NiIsIDEuMF0sIFsidDQ0NDciLCAxLjBdLCBbInQ0NDQ4IiwgMS4wXSwgWyJ0NDQ0OSIsIDEuMF0sIFsidDQ0NTAiLCAxLjBdLCBbInQ0NDUxIiwgMS4wXSwgWyJ0NDQ1MiIsIDEuMF0sIFsidDQ0NTMiLCAxLjBdLCBbInQ0NDU0IiwgMS4wXSwgWyJ0NDQ1NSIsIDEuMF0sIFsidDQ0NTYiLCAxLjBdLCBbInQ0NDU3IiwgMS4wXSwgWyJ0NDQ1OCIsIDEuMF0sIFsidDQ0NTkiLCAxLjBdLCBbInQ0NDYwIiwgMS4wXSwgWyJ0NDQ2MSIsIDEuMF0sIFsidDQ0NjIiLCAxLjBdLCBbInQ0NDYzIiwgMS4wXSwgWyJ0NDQ2NCIsIDEuMF0sIFsidDQ0NjUiLCAxLjBdLCBbInQ0NDY2IiwgMS4wXSwgWyJ0NDQ2NyIsIDEuMF0sIFsidDQ0NjgiLCAxLjBdLCBbInQ0NDY5IiwgMS4wXSwgWyJ0NDQ3MCIsIDEuMF0sIFsidDQ0NzEiLCAxLjBdLCBbInQ0NDcyIiwgMS4wXSwgWyJ0NDQ3MyIsIDEuMF0sIFsidDQ0NzQiLCAxLjBdLCBbInQ0NDc1IiwgMS4wXSwgWyJ0NDQ3NiIsIDEuMF0sIFsidDQ0NzciLCAxLjBdLCBbInQ0NDc4IiwgMS4wXSwgWyJ0NDQ3OSIsIDEuMF0sIFsidDQ0ODAiLCAxLjBdLCBbInQ0NDgxIiwgMS4wXSwgWyJ0NDQ4MiIsIDEuMF0sIFsidDQ0ODMiLCAxLjBdLCBbInQ0NDg0IiwgMS4wXSwgWyJ0NDQ4NSIsIDEuMF0sIFsidDQ0ODYiLCAxLjBdLCBbInQ0NDg3IiwgMS4wXSwgWyJ0NDQ4OCIsIDEuMF0sIFsidDQ0ODkiLCAxLjBdLCBbInQ0NDkwIiwgMS4wXSwgWyJ0NDQ5MSIsIDEuMF0sIFsidDQ0OTIiLCAxLjBdLCBbInQ0NDkzIiwgMS4wXSwgWyJ0NDQ5NCIsIDEuMF0sIFsidDQ0OTUiLCAxLjBdLCBbInQ0NDk2IiwgMS4wXSwgWyJ0NDQ5NyIsIDEuMF0sIFsidDQ0OTgiLCAxLjBdLCBbInQ0NDk5IiwgMS4wXSwgWyJ0NDUwMCIsIDEuMF0sIFsidDQ1MDEiLCAxLjBdLCBbInQ0NTAyIiwgMS4wXSwgWyJ0NDUwMyIsIDEuMF0sIFsidDQ1MDQiLCAxLjBdLCBbInQ0NTA1IiwgMS4wXSwgWyJ0NDUwNiIsIDEuMF0sIFsidDQ1MDciLCAxLjBdLCBbInQ0NTA4IiwgMS4wXSwgWyJ0NDUwOSIsIDEuMF0sIFsidDQ1MTAiLCAxLjBdLCBbInQ0NTExIiwgMS4wXSwgWyJ0NDUxMiIsIDEuMF0sIFsidDQ1MTMiLCAxLjBdLCBbInQ0NTE0IiwgMS4wXSwgWyJ0NDUxNSIsIDEuMF0sIFsidDQ1MTYiLCAxLjBdLCBbInQ0NTE3IiwgMS4wXSwgWyJ0NDUxOCIsIDEuMF0sIFsidDQ1MTkiLCAxLjBdLCBbInQ0NTIwIiwgMS4wXSwgWyJ0NDUyMSIsIDEuMF0sIFsidDQ1MjIiLCAxLjBdLCBbInQ0NTIzIiwgMS4wXSwgWyJ0NDUyNCIsIDEuMF0sIFsidDQ1MjUiLCAxLjBdLCBbInQ0NTI2IiwgMS4wXSwgWyJ0NDUyNyIsIDEuMF0sIFsidDQ1MjgiLCAxLjBdLCBbInQ0NTI5IiwgMS4wXSwgWyJ0NDUzMCIsIDEuMF0sIFsidDQ1MzEiLCAxLjBdLCBbInQ0NTMyIiwgMS4wXSwgWyJ0NDUzMyIsIDEuMF0sIFsidDQ1MzQiLCAxLjBdLCBbInQ0NTM1IiwgMS4wXSwgWyJ0NDUzNiIsIDEuMF0sIFsidDQ1MzciLCAxLjBdLCBbInQ0NTM4IiwgMS4wXSwgWyJ0NDUzOSIsIDEuMF0sIFsidDQ1NDAiLCAxLjBdLCBbInQ0NTQxIiwgMS4wXSwgWyJ0NDU0MiIsIDEuMF0sIFsidDQ1NDMiLCAxLjBdLCBbInQ0NTQ0IiwgMS4wXSwgWyJ0NDU0NSIsIDEuMF0sIFsidDQ1NDYiLCAxLjBdLCBbInQ0NTQ3IiwgMS4wXSwgWyJ0NDU0OCIsIDEuMF0sIFsidDQ1NDkiLCAxLjBdLCBbInQ0NTUwIiwgMS4wXSwgWyJ0NDU1MSIsIDEuMF0sIFsidDQ1NTIiLCAxLjBdLCBbInQ0NTUzIiwgMS4wXSwgWyJ0NDU1NCIsIDEuMF0sIFsidDQ1NTUiLCAxLjBdLCBbInQ0NTU2IiwgMS4wXSwgWyJ0NDU1NyIsIDEuMF0sIFsidDQ1NTgiLCAxLjBdLCBbInQ0NTU5IiwgMS4wXSwgWyJ0NDU2MCIsIDEuMF0sIFsidDQ1NjEiLCAxLjBdLCBbInQ0NTYyIiwgMS4wXSwgWyJ0NDU2MyIsIDEuMF0sIFsidDQ1NjQiLCAxLjBdLCBbInQ0NTY1IiwgMS4wXSwgWyJ0NDU2NiIsIDEuMF0sIFsidDQ1NjciLCAxLjBdLCBbInQ0NTY4IiwgMS4wXSwgWyJ0NDU2OSIsIDEuMF0sIFsidDQ1NzAiLCAxLjBdLCBbInQ0NTcxIiwgMS4wXSwgWyJ0NDU3MiIsIDEuMF0sIFsidDQ1NzMiLCAxLjBdLCBbInQ0NTc0IiwgMS4wXSwgWyJ0NDU3NSIsIDEuMF0sIFsidDQ1NzYiLCAxLjBdLCBbInQ0NTc3IiwgMS4wXSwgWyJ0NDU3OCIsIDEuMF0sIFsidDQ1NzkiLCAxLjBdLCBbInQ0NTgwIiwgMS4wXSwgWyJ0NDU4MSIsIDEuMF0sIFsidDQ1ODIiLCAxLjBdLCBbInQ0NTgzIiwgMS4wXSwgWyJ0NDU4NCIsIDEuMF0sIFsidDQ1ODUiLCAxLjBdLCBbInQ0NTg2IiwgMS4wXSwgWyJ0NDU4NyIsIDEuMF0sIFsidDQ1ODgiLCAxLjBdLCBbInQ0NTg5IiwgMS4wXSwgWyJ0NDU5MCIsIDEuMF0sIFsidDQ1OTEiLCAxLjBdLCBbInQ0NTkyIiwgMS4wXSwgWyJ0NDU5MyIsIDEuMF0sIFsidDQ1OTQiLCAxLjBdLCBbInQ0NTk1IiwgMS4wXSwgWyJ0NDU5NiIsIDEuMF0sIFsidDQ1OTciLCAxLjBdLCBbInQ0NTk4IiwgMS4wXSwgWyJ0NDU5OSIsIDEuMF0sIFsidDQ2MDAiLCAxLjBdLCBbInQ0NjAxIiwgMS4wXSwgWyJ0NDYwMiIsIDEuMF0sIFsidDQ2MDMiLCAxLjBdLCBbInQ0NjA0IiwgMS4wXSwgWyJ0NDYwNSIsIDEuMF0sIFsidDQ2MDYiLCAxLjBdLCBbInQ0NjA3IiwgMS4wXSwgWyJ0NDYwOCIsIDEuMF0sIFsidDQ2MDkiLCAxLjBdLCBbInQ0NjEwIiwgMS4wXSwgWyJ0NDYxMSIsIDEuMF0sIFsidDQ2MTIiLCAxLjBdLCBbInQ0NjEzIiwgMS4wXSwgWyJ0NDYxNCIsIDEuMF0sIFsidDQ2MTUiLCAxLjBdLCBbInQ0NjE2IiwgMS4wXSwgWyJ0NDYxNyIsIDEuMF0sIFsidDQ2MTgiLCAxLjBdLCBbInQ0NjE5IiwgMS4wXSwgWyJ0NDYyMCIsIDEuMF0sIFsidDQ2MjEiLCAxLjBdLCBbInQ0NjIyIiwgMS4wXSwgWyJ0NDYyMyIsIDEuMF0sIFsidDQ2MjQiLCAxLjBdLCBbInQ0NjI1IiwgMS4wXSwgWyJ0NDYyNiIsIDEuMF0sIFsidDQ2MjciLCAxLjBdLCBbInQ0NjI4IiwgMS4wXSwgWyJ0NDYyOSIsIDEuMF0sIFsidDQ2MzAiLCAxLjBdLCBbInQ0NjMxIiwgMS4wXSwgWyJ0NDYzMiIsIDEuMF0sIFsidDQ2MzMiLCAxLjBdLCBbInQ0NjM0IiwgMS4wXSwgWyJ0NDYzNSIsIDEuMF0sIFsidDQ2MzYiLCAxLjBdLCBbInQ0NjM3IiwgMS4wXSwgWyJ0NDYzOCIsIDEuMF0sIFsidDQ2MzkiLCAxLjBdLCBbInQ0NjQwIiwgMS4wXSwgWyJ0NDY0MSIsIDEuMF0sIFsidDQ2NDIiLCAxLjBdLCBbInQ0NjQzIiwgMS4wXSwgWyJ0NDY0NCIsIDEuMF0sIFsidDQ2NDUiLCAxLjBdLCBbInQ0NjQ2IiwgMS4wXSwgWyJ0NDY0NyIsIDEuMF0sIFsidDQ2NDgiLCAxLjBdLCBbInQ0NjQ5IiwgMS4wXSwgWyJ0NDY1MCIsIDEuMF0sIFsidDQ2NTEiLCAxLjBdLCBbInQ0NjUyIiwgMS4wXSwgWyJ0NDY1MyIsIDEuMF0sIFsidDQ2NTQiLCAxLjBdLCBbInQ0NjU1IiwgMS4wXSwgWyJ0NDY1NiIsIDEuMF0sIFsidDQ2NTciLCAxLjBdLCBbInQ0NjU4IiwgMS4wXSwgWyJ0NDY1OSIsIDEuMF0sIFsidDQ2NjAiLCAxLjBdLCBbInQ0NjYxIiwgMS4wXSwgWyJ0NDY2MiIsIDEuMF0sIFsidDQ2NjMiLCAxLjBdLCBbInQ0NjY0IiwgMS4wXSwgWyJ0NDY2NSIsIDEuMF0sIFsidDQ2NjYiLCAxLjBdLCBbInQ0NjY3IiwgMS4wXSwgWyJ0NDY2OCIsIDEuMF0sIFsidDQ2NjkiLCAxLjBdLCBbInQ0NjcwIiwgMS4wXSwgWyJ0NDY3MSIsIDEuMF0sIFsidDQ2NzIiLCAxLjBdLCBbInQ0NjczIiwgMS4wXSwgWyJ0NDY3NCIsIDEuMF0sIFsidDQ2NzUiLCAxLjBdLCBbInQ0Njc2IiwgMS4wXSwgWyJ0NDY3NyIsIDEuMF0sIFsidDQ2NzgiLCAxLjBdLCBbInQ0Njc5IiwgMS4wXSwgWyJ0NDY4MCIsIDEuMF0sIFsidDQ2ODEiLCAxLjBdLCBbInQ0NjgyIiwgMS4wXSwgWyJ0NDY4MyIsIDEuMF0sIFsidDQ2ODQiLCAxLjBdLCBbInQ0Njg1IiwgMS4wXSwgWyJ0NDY4NiIsIDEuMF0sIFsidDQ2ODciLCAxLjBdLCBbInQ0Njg4IiwgMS4wXSwgWyJ0NDY4OSIsIDEuMF0sIFsidDQ2OTAiLCAxLjBdLCBbInQ0NjkxIiwgMS4wXSwgWyJ0NDY5MiIsIDEuMF0sIFsidDQ2OTMiLCAxLjBdLCBbInQ0Njk0IiwgMS4wXSwgWyJ0NDY5NSIsIDEuMF0sIFsidDQ2OTYiLCAxLjBdLCBbInQ0Njk3IiwgMS4wXSwgWyJ0NDY5OCIsIDEuMF0sIFsidDQ2OTkiLCAxLjBdLCBbInQ0NzAwIiwgMS4wXSwgWyJ0NDcwMSIsIDEuMF0sIFsidDQ3MDIiLCAxLjBdLCBbInQ0NzAzIiwgMS4wXSwgWyJ0NDcwNCIsIDEuMF0sIFsidDQ3MDUiLCAxLjBdLCBbInQ0NzA2IiwgMS4wXSwgWyJ0NDcwNyIsIDEuMF0sIFsidDQ3MDgiLCAxLjBdLCBbInQ0NzA5IiwgMS4wXSwgWyJ0NDcxMCIsIDEuMF0sIFsidDQ3MTEiLCAxLjBdLCBbInQ0NzEyIiwgMS4wXSwgWyJ0NDcxMyIsIDEuMF0sIFsidDQ3MTQiLCAxLjBdLCBbInQ0NzE1IiwgMS4wXSwgWyJ0NDcxNiIsIDEuMF0sIFsidDQ3MTciLCAxLjBdLCBbInQ0NzE4IiwgMS4wXSwgWyJ0NDcxOSIsIDEuMF0sIFsidDQ3MjAiLCAxLjBdLCBbInQ0NzIxIiwgMS4wXSwgWyJ0NDcyMiIsIDEuMF0sIFsidDQ3MjMiLCAxLjBdLCBbInQ0NzI0IiwgMS4wXSwgWyJ0NDcyNSIsIDEuMF0sIFsidDQ3MjYiLCAxLjBdLCBbInQ0NzI3IiwgMS4wXSwgWyJ0NDcyOCIsIDEuMF0sIFsidDQ3MjkiLCAxLjBdLCBbInQ0NzMwIiwgMS4wXSwgWyJ0NDczMSIsIDEuMF0sIFsidDQ3MzIiLCAxLjBdLCBbInQ0NzMzIiwgMS4wXSwgWyJ0NDczNCIsIDEuMF0sIFsidDQ3MzUiLCAxLjBdLCBbInQ0NzM2IiwgMS4wXSwgWyJ0NDczNyIsIDEuMF0sIFsidDQ3MzgiLCAxLjBdLCBbInQ0NzM5IiwgMS4wXSwgWyJ0NDc0MCIsIDEuMF0sIFsidDQ3NDEiLCAxLjBdLCBbInQ0NzQyIiwgMS4wXSwgWyJ0NDc0MyIsIDEuMF0sIFsidDQ3NDQiLCAxLjBdLCBbInQ0NzQ1IiwgMS4wXSwgWyJ0NDc0NiIsIDEuMF0sIFsidDQ3NDciLCAxLjBdLCBbInQ0NzQ4IiwgMS4wXSwgWyJ0NDc0OSIsIDEuMF0sIFsidDQ3NTAiLCAxLjBdLCBbInQ0NzUxIiwgMS4wXSwgWyJ0NDc1MiIsIDEuMF0sIFsidDQ3NTMiLCAxLjBdLCBbInQ0NzU0IiwgMS4wXSwgWyJ0NDc1NSIsIDEuMF0sIFsidDQ3NTYiLCAxLjBdLCBbInQ0NzU3IiwgMS4wXSwgWyJ0NDc1OCIsIDEuMF0sIFsidDQ3NTkiLCAxLjBdLCBbInQ0NzYwIiwgMS4wXSwgWyJ0NDc2MSIsIDEuMF0sIFsidDQ3NjIiLCAxLjBdLCBbInQ0NzYzIiwgMS4wXSwgWyJ0NDc2NCIsIDEuMF0sIFsidDQ3NjUiLCAxLjBdLCBbInQ0NzY2IiwgMS4wXSwgWyJ0NDc2NyIsIDEuMF0sIFsidDQ3NjgiLCAxLjBdLCBbInQ0NzY5IiwgMS4wXSwgWyJ0NDc3MCIsIDEuMF0sIFsidDQ3NzEiLCAxLjBdLCBbInQ0NzcyIiwgMS4wXSwgWyJ0NDc3MyIsIDEuMF0sIFsidDQ3NzQiLCAxLjBdLCBbInQ0Nzc1IiwgMS4wXSwgWyJ0NDc3NiIsIDEuMF0sIFsidDQ3NzciLCAxLjBdLCBbInQ0Nzc4IiwgMS4wXSwgWyJ0NDc3OSIsIDEuMF0sIFsidDQ3ODAiLCAxLjBdLCBbInQ0NzgxIiwgMS4wXSwgWyJ0NDc4MiIsIDEuMF0sIFsidDQ3ODMiLCAxLjBdLCBbInQ0Nzg0IiwgMS4wXSwgWyJ0NDc4NSIsIDEuMF0sIFsidDQ3ODYiLCAxLjBdLCBbInQ0Nzg3IiwgMS4wXSwgWyJ0NDc4OCIsIDEuMF0sIFsidDQ3ODkiLCAxLjBdLCBbInQ0NzkwIiwgMS4wXSwgWyJ0NDc5MSIsIDEuMF0sIFsidDQ3OTIiLCAxLjBdLCBbInQ0NzkzIiwgMS4wXSwgWyJ0NDc5NCIsIDEuMF0sIFsidDQ3OTUiLCAxLjBdLCBbInQ0Nzk2IiwgMS4wXSwgWyJ0NDc5NyIsIDEuMF0sIFsidDQ3OTgiLCAxLjBdLCBbInQ0Nzk5IiwgMS4wXSwgWyJ0NDgwMCIsIDEuMF0sIFsidDQ4MDEiLCAxLjBdLCBbInQ0ODAyIiwgMS4wXSwgWyJ0NDgwMyIsIDEuMF0sIFsidDQ4MDQiLCAxLjBdLCBbInQ0ODA1IiwgMS4wXSwgWyJ0NDgwNiIsIDEuMF0sIFsidDQ4MDciLCAxLjBdLCBbInQ0ODA4IiwgMS4wXSwgWyJ0NDgwOSIsIDEuMF0sIFsidDQ4MTAiLCAxLjBdLCBbInQ0ODExIiwgMS4wXSwgWyJ0NDgxMiIsIDEuMF0sIFsidDQ4MTMiLCAxLjBdLCBbInQ0ODE0IiwgMS4wXSwgWyJ0NDgxNSIsIDEuMF0sIFsidDQ4MTYiLCAxLjBdLCBbInQ0ODE3IiwgMS4wXSwgWyJ0NDgxOCIsIDEuMF0sIFsidDQ4MTkiLCAxLjBdLCBbInQ0ODIwIiwgMS4wXSwgWyJ0NDgyMSIsIDEuMF0sIFsidDQ4MjIiLCAxLjBdLCBbInQ0ODIzIiwgMS4wXSwgWyJ0NDgyNCIsIDEuMF0sIFsidDQ4MjUiLCAxLjBdLCBbInQ0ODI2IiwgMS4wXSwgWyJ0NDgyNyIsIDEuMF0sIFsidDQ4MjgiLCAxLjBdLCBbInQ0ODI5IiwgMS4wXSwgWyJ0NDgzMCIsIDEuMF0sIFsidDQ4MzEiLCAxLjBdLCBbInQ0ODMyIiwgMS4wXSwgWyJ0NDgzMyIsIDEuMF0sIFsidDQ4MzQiLCAxLjBdLCBbInQ0ODM1IiwgMS4wXSwgWyJ0NDgzNiIsIDEuMF0sIFsidDQ4MzciLCAxLjBdLCBbInQ0ODM4IiwgMS4wXSwgWyJ0NDgzOSIsIDEuMF0sIFsidDQ4NDAiLCAxLjBdLCBbInQ0ODQxIiwgMS4wXSwgWyJ0NDg0MiIsIDEuMF0sIFsidDQ4NDMiLCAxLjBdLCBbInQ0ODQ0IiwgMS4wXSwgWyJ0NDg0NSIsIDEuMF0sIFsidDQ4NDYiLCAxLjBdLCBbInQ0ODQ3IiwgMS4wXSwgWyJ0NDg0OCIsIDEuMF0sIFsidDQ4NDkiLCAxLjBdLCBbInQ0ODUwIiwgMS4wXSwgWyJ0NDg1MSIsIDEuMF0sIFsidDQ4NTIiLCAxLjBdLCBbInQ0ODUzIiwgMS4wXSwgWyJ0NDg1NCIsIDEuMF0sIFsidDQ4NTUiLCAxLjBdLCBbInQ0ODU2IiwgMS4wXSwgWyJ0NDg1NyIsIDEuMF0sIFsidDQ4NTgiLCAxLjBdLCBbInQ0ODU5IiwgMS4wXSwgWyJ0NDg2MCIsIDEuMF0sIFsidDQ4NjEiLCAxLjBdLCBbInQ0ODYyIiwgMS4wXSwgWyJ0NDg2MyIsIDEuMF0sIFsidDQ4NjQiLCAxLjBdLCBbInQ0ODY1IiwgMS4wXSwgWyJ0NDg2NiIsIDEuMF0sIFsidDQ4NjciLCAxLjBdLCBbInQ0ODY4IiwgMS4wXSwgWyJ0NDg2OSIsIDEuMF0sIFsidDQ4NzAiLCAxLjBdLCBbInQ0ODcxIiwgMS4wXSwgWyJ0NDg3MiIsIDEuMF0sIFsidDQ4NzMiLCAxLjBdLCBbInQ0ODc0IiwgMS4wXSwgWyJ0NDg3NSIsIDEuMF0sIFsidDQ4NzYiLCAxLjBdLCBbInQ0ODc3IiwgMS4wXSwgWyJ0NDg3OCIsIDEuMF0sIFsidDQ4NzkiLCAxLjBdLCBbInQ0ODgwIiwgMS4wXSwgWyJ0NDg4MSIsIDEuMF0sIFsidDQ4ODIiLCAxLjBdLCBbInQ0ODgzIiwgMS4wXSwgWyJ0NDg4NCIsIDEuMF0sIFsidDQ4ODUiLCAxLjBdLCBbInQ0ODg2IiwgMS4wXSwgWyJ0NDg4NyIsIDEuMF0sIFsidDQ4ODgiLCAxLjBdLCBbInQ0ODg5IiwgMS4wXSwgWyJ0NDg5MCIsIDEuMF0sIFsidDQ4OTEiLCAxLjBdLCBbInQ0ODkyIiwgMS4wXSwgWyJ0NDg5MyIsIDEuMF0sIFsidDQ4OTQiLCAxLjBdLCBbInQ0ODk1IiwgMS4wXSwgWyJ0NDg5NiIsIDEuMF0sIFsidDQ4OTciLCAxLjBdLCBbInQ0ODk4IiwgMS4wXSwgWyJ0NDg5OSIsIDEuMF0sIFsidDQ5MDAiLCAxLjBdLCBbInQ0OTAxIiwgMS4wXSwgWyJ0NDkwMiIsIDEuMF0sIFsidDQ5MDMiLCAxLjBdLCBbInQ0OTA0IiwgMS4wXSwgWyJ0NDkwNSIsIDEuMF0sIFsidDQ5MDYiLCAxLjBdLCBbInQ0OTA3IiwgMS4wXSwgWyJ0NDkwOCIsIDEuMF0sIFsidDQ5MDkiLCAxLjBdLCBbInQ0OTEwIiwgMS4wXSwgWyJ0NDkxMSIsIDEuMF0sIFsidDQ5MTIiLCAxLjBdLCBbInQ0OTEzIiwgMS4wXSwgWyJ0NDkxNCIsIDEuMF0sIFsidDQ5MTUiLCAxLjBdLCBbInQ0OTE2IiwgMS4wXSwgWyJ0NDkxNyIsIDEuMF0sIFsidDQ5MTgiLCAxLjBdLCBbInQ0OTE5IiwgMS4wXSwgWyJ0NDkyMCIsIDEuMF0sIFsidDQ5MjEiLCAxLjBdLCBbInQ0OTIyIiwgMS4wXSwgWyJ0NDkyMyIsIDEuMF0sIFsidDQ5MjQiLCAxLjBdLCBbInQ0OTI1IiwgMS4wXSwgWyJ0NDkyNiIsIDEuMF0sIFsidDQ5MjciLCAxLjBdLCBbInQ0OTI4IiwgMS4wXSwgWyJ0NDkyOSIsIDEuMF0sIFsidDQ5MzAiLCAxLjBdLCBbInQ0OTMxIiwgMS4wXSwgWyJ0NDkzMiIsIDEuMF0sIFsidDQ5MzMiLCAxLjBdLCBbInQ0OTM0IiwgMS4wXSwgWyJ0NDkzNSIsIDEuMF0sIFsidDQ5MzYiLCAxLjBdLCBbInQ0OTM3IiwgMS4wXSwgWyJ0NDkzOCIsIDEuMF0sIFsidDQ5MzkiLCAxLjBdLCBbInQ0OTQwIiwgMS4wXSwgWyJ0NDk0MSIsIDEuMF0sIFsidDQ5NDIiLCAxLjBdLCBbInQ0OTQzIiwgMS4wXSwgWyJ0NDk0NCIsIDEuMF0sIFsidDQ5NDUiLCAxLjBdLCBbInQ0OTQ2IiwgMS4wXSwgWyJ0NDk0NyIsIDEuMF0sIFsidDQ5NDgiLCAxLjBdLCBbInQ0OTQ5IiwgMS4wXSwgWyJ0NDk1MCIsIDEuMF0sIFsidDQ5NTEiLCAxLjBdLCBbInQ0OTUyIiwgMS4wXSwgWyJ0NDk1MyIsIDEuMF0sIFsidDQ5NTQiLCAxLjBdLCBbInQ0OTU1IiwgMS4wXSwgWyJ0NDk1NiIsIDEuMF0sIFsidDQ5NTciLCAxLjBdLCBbInQ0OTU4IiwgMS4wXSwgWyJ0NDk1OSIsIDEuMF0sIFsidDQ5NjAiLCAxLjBdLCBbInQ0OTYxIiwgMS4wXSwgWyJ0NDk2MiIsIDEuMF0sIFsidDQ5NjMiLCAxLjBdLCBbInQ0OTY0IiwgMS4wXSwgWyJ0NDk2NSIsIDEuMF0sIFsidDQ5NjYiLCAxLjBdLCBbInQ0OTY3IiwgMS4wXSwgWyJ0NDk2OCIsIDEuMF0sIFsidDQ5NjkiLCAxLjBdLCBbInQ0OTcwIiwgMS4wXSwgWyJ0NDk3MSIsIDEuMF0sIFsidDQ5NzIiLCAxLjBdLCBbInQ0OTczIiwgMS4wXSwgWyJ0NDk3NCIsIDEuMF0sIFsidDQ5NzUiLCAxLjBdLCBbInQ0OTc2IiwgMS4wXSwgWyJ0NDk3NyIsIDEuMF0sIFsidDQ5NzgiLCAxLjBdLCBbInQ0OTc5IiwgMS4wXSwgWyJ0NDk4MCIsIDEuMF0sIFsidDQ5ODEiLCAxLjBdLCBbInQ0OTgyIiwgMS4wXSwgWyJ0NDk4MyIsIDEuMF0sIFsidDQ5ODQiLCAxLjBdLCBbInQ0OTg1IiwgMS4wXSwgWyJ0NDk4NiIsIDEuMF0sIFsidDQ5ODciLCAxLjBdLCBbInQ0OTg4IiwgMS4wXSwgWyJ0NDk4OSIsIDEuMF0sIFsidDQ5OTAiLCAxLjBdLCBbInQ0OTkxIiwgMS4wXSwgWyJ0NDk5MiIsIDEuMF0sIFsidDQ5OTMiLCAxLjBdLCBbInQ0OTk0IiwgMS4wXSwgWyJ0NDk5NSIsIDEuMF0sIFsidDQ5OTYiLCAxLjBdLCBbInQ0OTk3IiwgMS4wXSwgWyJ0NDk5OCIsIDEuMF0sIFsidDQ5OTkiLCAxLjBdLCBbInQ1MDAwIiwgMS4wXSwgWyJ0NTAwMSIsIDEuMF0sIFsidDUwMDIiLCAxLjBdLCBbInQ1MDAzIiwgMS4wXSwgWyJ0NTAwNCIsIDEuMF0sIFsidDUwMDUiLCAxLjBdLCBbInQ1MDA2IiwgMS4wXSwgWyJ0NTAwNyIsIDEuMF0sIFsidDUwMDgiLCAxLjBdLCBbInQ1MDA5IiwgMS4wXSwgWyJ0NTAxMCIsIDEuMF0sIFsidDUwMTEiLCAxLjBdLCBbInQ1MDEyIiwgMS4wXSwgWyJ0NTAxMyIsIDEuMF0sIFsidDUwMTQiLCAxLjBdLCBbInQ1MDE1IiwgMS4wXSwgWyJ0NTAxNiIsIDEuMF0sIFsidDUwMTciLCAxLjBdLCBbInQ1MDE4IiwgMS4wXSwgWyJ0NTAxOSIsIDEuMF0sIFsidDUwMjAiLCAxLjBdLCBbInQ1MDIxIiwgMS4wXSwgWyJ0NTAyMiIsIDEuMF0sIFsidDUwMjMiLCAxLjBdLCBbInQ1MDI0IiwgMS4wXSwgWyJ0NTAyNSIsIDEuMF0sIFsidDUwMjYiLCAxLjBdLCBbInQ1MDI3IiwgMS4wXSwgWyJ0NTAyOCIsIDEuMF0sIFsidDUwMjkiLCAxLjBdLCBbInQ1MDMwIiwgMS4wXSwgWyJ0NTAzMSIsIDEuMF0sIFsidDUwMzIiLCAxLjBdLCBbInQ1MDMzIiwgMS4wXSwgWyJ0NTAzNCIsIDEuMF0sIFsidDUwMzUiLCAxLjBdLCBbInQ1MDM2IiwgMS4wXSwgWyJ0NTAzNyIsIDEuMF0sIFsidDUwMzgiLCAxLjBdLCBbInQ1MDM5IiwgMS4wXSwgWyJ0NTA0MCIsIDEuMF0sIFsidDUwNDEiLCAxLjBdLCBbInQ1MDQyIiwgMS4wXSwgWyJ0NTA0MyIsIDEuMF0sIFsidDUwNDQiLCAxLjBdLCBbInQ1MDQ1IiwgMS4wXSwgWyJ0NTA0NiIsIDEuMF0sIFsidDUwNDciLCAxLjBdLCBbInQ1MDQ4IiwgMS4wXSwgWyJ0NTA0OSIsIDEuMF0sIFsidDUwNTAiLCAxLjBdLCBbInQ1MDUxIiwgMS4wXSwgWyJ0NTA1MiIsIDEuMF0sIFsidDUwNTMiLCAxLjBdLCBbInQ1MDU0IiwgMS4wXSwgWyJ0NTA1NSIsIDEuMF0sIFsidDUwNTYiLCAxLjBdLCBbInQ1MDU3IiwgMS4wXSwgWyJ0NTA1OCIsIDEuMF0sIFsidDUwNTkiLCAxLjBdLCBbInQ1MDYwIiwgMS4wXSwgWyJ0NTA2MSIsIDEuMF0sIFsidDUwNjIiLCAxLjBdLCBbInQ1MDYzIiwgMS4wXSwgWyJ0NTA2NCIsIDEuMF0sIFsidDUwNjUiLCAxLjBdLCBbInQ1MDY2IiwgMS4wXSwgWyJ0NTA2NyIsIDEuMF0sIFsidDUwNjgiLCAxLjBdLCBbInQ1MDY5IiwgMS4wXSwgWyJ0NTA3MCIsIDEuMF0sIFsidDUwNzEiLCAxLjBdLCBbInQ1MDcyIiwgMS4wXSwgWyJ0NTA3MyIsIDEuMF0sIFsidDUwNzQiLCAxLjBdLCBbInQ1MDc1IiwgMS4wXSwgWyJ0NTA3NiIsIDEuMF0sIFsidDUwNzciLCAxLjBdLCBbInQ1MDc4IiwgMS4wXSwgWyJ0NTA3OSIsIDEuMF0sIFsidDUwODAiLCAxLjBdLCBbInQ1MDgxIiwgMS4wXSwgWyJ0NTA4MiIsIDEuMF0sIFsidDUwODMiLCAxLjBdLCBbInQ1MDg0IiwgMS4wXSwgWyJ0NTA4NSIsIDEuMF0sIFsidDUwODYiLCAxLjBdLCBbInQ1MDg3IiwgMS4wXSwgWyJ0NTA4OCIsIDEuMF0sIFsidDUwODkiLCAxLjBdLCBbInQ1MDkwIiwgMS4wXSwgWyJ0NTA5MSIsIDEuMF0sIFsidDUwOTIiLCAxLjBdLCBbInQ1MDkzIiwgMS4wXSwgWyJ0NTA5NCIsIDEuMF0sIFsidDUwOTUiLCAxLjBdLCBbInQ1MDk2IiwgMS4wXSwgWyJ0NTA5NyIsIDEuMF0sIFsidDUwOTgiLCAxLjBdLCBbInQ1MDk5IiwgMS4wXSwgWyJ0NTEwMCIsIDEuMF0sIFsidDUxMDEiLCAxLjBdLCBbInQ1MTAyIiwgMS4wXSwgWyJ0NTEwMyIsIDEuMF0sIFsidDUxMDQiLCAxLjBdLCBbInQ1MTA1IiwgMS4wXSwgWyJ0NTEwNiIsIDEuMF0sIFsidDUxMDciLCAxLjBdLCBbInQ1MTA4IiwgMS4wXSwgWyJ0NTEwOSIsIDEuMF0sIFsidDUxMTAiLCAxLjBdLCBbInQ1MTExIiwgMS4wXSwgWyJ0NTExMiIsIDEuMF0sIFsidDUxMTMiLCAxLjBdLCBbInQ1MTE0IiwgMS4wXSwgWyJ0NTExNSIsIDEuMF0sIFsidDUxMTYiLCAxLjBdLCBbInQ1MTE3IiwgMS4wXSwgWyJ0NTExOCIsIDEuMF0sIFsidDUxMTkiLCAxLjBdLCBbInQ1MTIwIiwgMS4wXSwgWyJ0NTEyMSIsIDEuMF0sIFsidDUxMjIiLCAxLjBdLCBbInQ1MTIzIiwgMS4wXSwgWyJ0NTEyNCIsIDEuMF0sIFsidDUxMjUiLCAxLjBdLCBbInQ1MTI2IiwgMS4wXSwgWyJ0NTEyNyIsIDEuMF0sIFsidDUxMjgiLCAxLjBdLCBbInQ1MTI5IiwgMS4wXSwgWyJ0NTEzMCIsIDEuMF0sIFsidDUxMzEiLCAxLjBdLCBbInQ1MTMyIiwgMS4wXSwgWyJ0NTEzMyIsIDEuMF0sIFsidDUxMzQiLCAxLjBdLCBbInQ1MTM1IiwgMS4wXSwgWyJ0NTEzNiIsIDEuMF0sIFsidDUxMzciLCAxLjBdLCBbInQ1MTM4IiwgMS4wXSwgWyJ0NTEzOSIsIDEuMF0sIFsidDUxNDAiLCAxLjBdLCBbInQ1MTQxIiwgMS4wXSwgWyJ0NTE0MiIsIDEuMF0sIFsidDUxNDMiLCAxLjBdLCBbInQ1MTQ0IiwgMS4wXSwgWyJ0NTE0NSIsIDEuMF0sIFsidDUxNDYiLCAxLjBdLCBbInQ1MTQ3IiwgMS4wXSwgWyJ0NTE0OCIsIDEuMF0sIFsidDUxNDkiLCAxLjBdLCBbInQ1MTUwIiwgMS4wXSwgWyJ0NTE1MSIsIDEuMF0sIFsidDUxNTIiLCAxLjBdLCBbInQ1MTUzIiwgMS4wXSwgWyJ0NTE1NCIsIDEuMF0sIFsidDUxNTUiLCAxLjBdLCBbInQ1MTU2IiwgMS4wXSwgWyJ0NTE1NyIsIDEuMF0sIFsidDUxNTgiLCAxLjBdLCBbInQ1MTU5IiwgMS4wXSwgWyJ0NTE2MCIsIDEuMF0sIFsidDUxNjEiLCAxLjBdLCBbInQ1MTYyIiwgMS4wXSwgWyJ0NTE2MyIsIDEuMF0sIFsidDUxNjQiLCAxLjBdLCBbInQ1MTY1IiwgMS4wXSwgWyJ0NTE2NiIsIDEuMF0sIFsidDUxNjciLCAxLjBdLCBbInQ1MTY4IiwgMS4wXSwgWyJ0NTE2OSIsIDEuMF0sIFsidDUxNzAiLCAxLjBdLCBbInQ1MTcxIiwgMS4wXSwgWyJ0NTE3MiIsIDEuMF0sIFsidDUxNzMiLCAxLjBdLCBbInQ1MTc0IiwgMS4wXSwgWyJ0NTE3NSIsIDEuMF0sIFsidDUxNzYiLCAxLjBdLCBbInQ1MTc3IiwgMS4wXSwgWyJ0NTE3OCIsIDEuMF0sIFsidDUxNzkiLCAxLjBdLCBbInQ1MTgwIiwgMS4wXSwgWyJ0NTE4MSIsIDEuMF0sIFsidDUxODIiLCAxLjBdLCBbInQ1MTgzIiwgMS4wXSwgWyJ0NTE4NCIsIDEuMF0sIFsidDUxODUiLCAxLjBdLCBbInQ1MTg2IiwgMS4wXSwgWyJ0NTE4NyIsIDEuMF0sIFsidDUxODgiLCAxLjBdLCBbInQ1MTg5IiwgMS4wXSwgWyJ0NTE5MCIsIDEuMF0sIFsidDUxOTEiLCAxLjBdLCBbInQ1MTkyIiwgMS4wXSwgWyJ0NTE5MyIsIDEuMF0sIFsidDUxOTQiLCAxLjBdLCBbInQ1MTk1IiwgMS4wXSwgWyJ0NTE5NiIsIDEuMF0sIFsidDUxOTciLCAxLjBdLCBbInQ1MTk4IiwgMS4wXSwgWyJ0NTE5OSIsIDEuMF0sIFsidDUyMDAiLCAxLjBdLCBbInQ1MjAxIiwgMS4wXSwgWyJ0NTIwMiIsIDEuMF0sIFsidDUyMDMiLCAxLjBdLCBbInQ1MjA0IiwgMS4wXSwgWyJ0NTIwNSIsIDEuMF0sIFsidDUyMDYiLCAxLjBdLCBbInQ1MjA3IiwgMS4wXSwgWyJ0NTIwOCIsIDEuMF0sIFsidDUyMDkiLCAxLjBdLCBbInQ1MjEwIiwgMS4wXSwgWyJ0NTIxMSIsIDEuMF0sIFsidDUyMTIiLCAxLjBdLCBbInQ1MjEzIiwgMS4wXSwgWyJ0NTIxNCIsIDEuMF0sIFsidDUyMTUiLCAxLjBdLCBbInQ1MjE2IiwgMS4wXSwgWyJ0NTIxNyIsIDEuMF0sIFsidDUyMTgiLCAxLjBdLCBbInQ1MjE5IiwgMS4wXSwgWyJ0NTIyMCIsIDEuMF0sIFsidDUyMjEiLCAxLjBdLCBbInQ1MjIyIiwgMS4wXSwgWyJ0NTIyMyIsIDEuMF0sIFsidDUyMjQiLCAxLjBdLCBbInQ1MjI1IiwgMS4wXSwgWyJ0NTIyNiIsIDEuMF0sIFsidDUyMjciLCAxLjBdLCBbInQ1MjI4IiwgMS4wXSwgWyJ0NTIyOSIsIDEuMF0sIFsidDUyMzAiLCAxLjBdLCBbInQ1MjMxIiwgMS4wXSwgWyJ0NTIzMiIsIDEuMF0sIFsidDUyMzMiLCAxLjBdLCBbInQ1MjM0IiwgMS4wXSwgWyJ0NTIzNSIsIDEuMF0sIFsidDUyMzYiLCAxLjBdLCBbInQ1MjM3IiwgMS4wXSwgWyJ0NTIzOCIsIDEuMF0sIFsidDUyMzkiLCAxLjBdLCBbInQ1MjQwIiwgMS4wXSwgWyJ0NTI0MSIsIDEuMF0sIFsidDUyNDIiLCAxLjBdLCBbInQ1MjQzIiwgMS4wXSwgWyJ0NTI0NCIsIDEuMF0sIFsidDUyNDUiLCAxLjBdLCBbInQ1MjQ2IiwgMS4wXSwgWyJ0NTI0NyIsIDEuMF0sIFsidDUyNDgiLCAxLjBdLCBbInQ1MjQ5IiwgMS4wXSwgWyJ0NTI1MCIsIDEuMF0sIFsidDUyNTEiLCAxLjBdLCBbInQ1MjUyIiwgMS4wXSwgWyJ0NTI1MyIsIDEuMF0sIFsidDUyNTQiLCAxLjBdLCBbInQ1MjU1IiwgMS4wXSwgWyJ0NTI1NiIsIDEuMF0sIFsidDUyNTciLCAxLjBdLCBbInQ1MjU4IiwgMS4wXSwgWyJ0NTI1OSIsIDEuMF0sIFsidDUyNjAiLCAxLjBdLCBbInQ1MjYxIiwgMS4wXSwgWyJ0NTI2MiIsIDEuMF0sIFsidDUyNjMiLCAxLjBdLCBbInQ1MjY0IiwgMS4wXSwgWyJ0NTI2NSIsIDEuMF0sIFsidDUyNjYiLCAxLjBdLCBbInQ1MjY3IiwgMS4wXSwgWyJ0NTI2OCIsIDEuMF0sIFsidDUyNjkiLCAxLjBdLCBbInQ1MjcwIiwgMS4wXSwgWyJ0NTI3MSIsIDEuMF0sIFsidDUyNzIiLCAxLjBdLCBbInQ1MjczIiwgMS4wXSwgWyJ0NTI3NCIsIDEuMF0sIFsidDUyNzUiLCAxLjBdLCBbInQ1Mjc2IiwgMS4wXSwgWyJ0NTI3NyIsIDEuMF0sIFsidDUyNzgiLCAxLjBdLCBbInQ1Mjc5IiwgMS4wXSwgWyJ0NTI4MCIsIDEuMF0sIFsidDUyODEiLCAxLjBdLCBbInQ1MjgyIiwgMS4wXSwgWyJ0NTI4MyIsIDEuMF0sIFsidDUyODQiLCAxLjBdLCBbInQ1Mjg1IiwgMS4wXSwgWyJ0NTI4NiIsIDEuMF0sIFsidDUyODciLCAxLjBdLCBbInQ1Mjg4IiwgMS4wXSwgWyJ0NTI4OSIsIDEuMF0sIFsidDUyOTAiLCAxLjBdLCBbInQ1MjkxIiwgMS4wXSwgWyJ0NTI5MiIsIDEuMF0sIFsidDUyOTMiLCAxLjBdLCBbInQ1Mjk0IiwgMS4wXSwgWyJ0NTI5NSIsIDEuMF0sIFsidDUyOTYiLCAxLjBdLCBbInQ1Mjk3IiwgMS4wXSwgWyJ0NTI5OCIsIDEuMF0sIFsidDUyOTkiLCAxLjBdLCBbInQ1MzAwIiwgMS4wXSwgWyJ0NTMwMSIsIDEuMF0sIFsidDUzMDIiLCAxLjBdLCBbInQ1MzAzIiwgMS4wXSwgWyJ0NTMwNCIsIDEuMF0sIFsidDUzMDUiLCAxLjBdLCBbInQ1MzA2IiwgMS4wXSwgWyJ0NTMwNyIsIDEuMF0sIFsidDUzMDgiLCAxLjBdLCBbInQ1MzA5IiwgMS4wXSwgWyJ0NTMxMCIsIDEuMF0sIFsidDUzMTEiLCAxLjBdLCBbInQ1MzEyIiwgMS4wXSwgWyJ0NTMxMyIsIDEuMF0sIFsidDUzMTQiLCAxLjBdLCBbInQ1MzE1IiwgMS4wXSwgWyJ0NTMxNiIsIDEuMF0sIFsidDUzMTciLCAxLjBdLCBbInQ1MzE4IiwgMS4wXSwgWyJ0NTMxOSIsIDEuMF0sIFsidDUzMjAiLCAxLjBdLCBbInQ1MzIxIiwgMS4wXSwgWyJ0NTMyMiIsIDEuMF0sIFsidDUzMjMiLCAxLjBdLCBbInQ1MzI0IiwgMS4wXSwgWyJ0NTMyNSIsIDEuMF0sIFsidDUzMjYiLCAxLjBdLCBbInQ1MzI3IiwgMS4wXSwgWyJ0NTMyOCIsIDEuMF0sIFsidDUzMjkiLCAxLjBdLCBbInQ1MzMwIiwgMS4wXSwgWyJ0NTMzMSIsIDEuMF0sIFsidDUzMzIiLCAxLjBdLCBbInQ1MzMzIiwgMS4wXSwgWyJ0NTMzNCIsIDEuMF0sIFsidDUzMzUiLCAxLjBdLCBbInQ1MzM2IiwgMS4wXSwgWyJ0NTMzNyIsIDEuMF0sIFsidDUzMzgiLCAxLjBdLCBbInQ1MzM5IiwgMS4wXSwgWyJ0NTM0MCIsIDEuMF0sIFsidDUzNDEiLCAxLjBdLCBbInQ1MzQyIiwgMS4wXSwgWyJ0NTM0MyIsIDEuMF0sIFsidDUzNDQiLCAxLjBdLCBbInQ1MzQ1IiwgMS4wXSwgWyJ0NTM0NiIsIDEuMF0sIFsidDUzNDciLCAxLjBdLCBbInQ1MzQ4IiwgMS4wXSwgWyJ0NTM0OSIsIDEuMF0sIFsidDUzNTAiLCAxLjBdLCBbInQ1MzUxIiwgMS4wXSwgWyJ0NTM1MiIsIDEuMF0sIFsidDUzNTMiLCAxLjBdLCBbInQ1MzU0IiwgMS4wXSwgWyJ0NTM1NSIsIDEuMF0sIFsidDUzNTYiLCAxLjBdLCBbInQ1MzU3IiwgMS4wXSwgWyJ0NTM1OCIsIDEuMF0sIFsidDUzNTkiLCAxLjBdLCBbInQ1MzYwIiwgMS4wXSwgWyJ0NTM2MSIsIDEuMF0sIFsidDUzNjIiLCAxLjBdLCBbInQ1MzYzIiwgMS4wXSwgWyJ0NTM2NCIsIDEuMF0sIFsidDUzNjUiLCAxLjBdLCBbInQ1MzY2IiwgMS4wXSwgWyJ0NTM2NyIsIDEuMF0sIFsidDUzNjgiLCAxLjBdLCBbInQ1MzY5IiwgMS4wXSwgWyJ0NTM3MCIsIDEuMF0sIFsidDUzNzEiLCAxLjBdLCBbInQ1MzcyIiwgMS4wXSwgWyJ0NTM3MyIsIDEuMF0sIFsidDUzNzQiLCAxLjBdLCBbInQ1Mzc1IiwgMS4wXSwgWyJ0NTM3NiIsIDEuMF0sIFsidDUzNzciLCAxLjBdLCBbInQ1Mzc4IiwgMS4wXSwgWyJ0NTM3OSIsIDEuMF0sIFsidDUzODAiLCAxLjBdLCBbInQ1MzgxIiwgMS4wXSwgWyJ0NTM4MiIsIDEuMF0sIFsidDUzODMiLCAxLjBdLCBbInQ1Mzg0IiwgMS4wXSwgWyJ0NTM4NSIsIDEuMF0sIFsidDUzODYiLCAxLjBdLCBbInQ1Mzg3IiwgMS4wXSwgWyJ0NTM4OCIsIDEuMF0sIFsidDUzODkiLCAxLjBdLCBbInQ1MzkwIiwgMS4wXSwgWyJ0NTM5MSIsIDEuMF0sIFsidDUzOTIiLCAxLjBdLCBbInQ1MzkzIiwgMS4wXSwgWyJ0NTM5NCIsIDEuMF0sIFsidDUzOTUiLCAxLjBdLCBbInQ1Mzk2IiwgMS4wXSwgWyJ0NTM5NyIsIDEuMF0sIFsidDUzOTgiLCAxLjBdLCBbInQ1Mzk5IiwgMS4wXSwgWyJ0NTQwMCIsIDEuMF0sIFsidDU0MDEiLCAxLjBdLCBbInQ1NDAyIiwgMS4wXSwgWyJ0NTQwMyIsIDEuMF0sIFsidDU0MDQiLCAxLjBdLCBbInQ1NDA1IiwgMS4wXSwgWyJ0NTQwNiIsIDEuMF0sIFsidDU0MDciLCAxLjBdLCBbInQ1NDA4IiwgMS4wXSwgWyJ0NTQwOSIsIDEuMF0sIFsidDU0MTAiLCAxLjBdLCBbInQ1NDExIiwgMS4wXSwgWyJ0NTQxMiIsIDEuMF0sIFsidDU0MTMiLCAxLjBdLCBbInQ1NDE0IiwgMS4wXSwgWyJ0NTQxNSIsIDEuMF0sIFsidDU0MTYiLCAxLjBdLCBbInQ1NDE3IiwgMS4wXSwgWyJ0NTQxOCIsIDEuMF0sIFsidDU0MTkiLCAxLjBdLCBbInQ1NDIwIiwgMS4wXSwgWyJ0NTQyMSIsIDEuMF0sIFsidDU0MjIiLCAxLjBdLCBbInQ1NDIzIiwgMS4wXSwgWyJ0NTQyNCIsIDEuMF0sIFsidDU0MjUiLCAxLjBdLCBbInQ1NDI2IiwgMS4wXSwgWyJ0NTQyNyIsIDEuMF0sIFsidDU0MjgiLCAxLjBdLCBbInQ1NDI5IiwgMS4wXSwgWyJ0NTQzMCIsIDEuMF0sIFsidDU0MzEiLCAxLjBdLCBbInQ1NDMyIiwgMS4wXSwgWyJ0NTQzMyIsIDEuMF0sIFsidDU0MzQiLCAxLjBdLCBbInQ1NDM1IiwgMS4wXSwgWyJ0NTQzNiIsIDEuMF0sIFsidDU0MzciLCAxLjBdLCBbInQ1NDM4IiwgMS4wXSwgWyJ0NTQzOSIsIDEuMF0sIFsidDU0NDAiLCAxLjBdLCBbInQ1NDQxIiwgMS4wXSwgWyJ0NTQ0MiIsIDEuMF0sIFsidDU0NDMiLCAxLjBdLCBbInQ1NDQ0IiwgMS4wXSwgWyJ0NTQ0NSIsIDEuMF0sIFsidDU0NDYiLCAxLjBdLCBbInQ1NDQ3IiwgMS4wXSwgWyJ0NTQ0OCIsIDEuMF0sIFsidDU0NDkiLCAxLjBdLCBbInQ1NDUwIiwgMS4wXSwgWyJ0NTQ1MSIsIDEuMF0sIFsidDU0NTIiLCAxLjBdLCBbInQ1NDUzIiwgMS4wXSwgWyJ0NTQ1NCIsIDEuMF0sIFsidDU0NTUiLCAxLjBdLCBbInQ1NDU2IiwgMS4wXSwgWyJ0NTQ1NyIsIDEuMF0sIFsidDU0NTgiLCAxLjBdLCBbInQ1NDU5IiwgMS4wXSwgWyJ0NTQ2MCIsIDEuMF0sIFsidDU0NjEiLCAxLjBdLCBbInQ1NDYyIiwgMS4wXSwgWyJ0NTQ2MyIsIDEuMF0sIFsidDU0NjQiLCAxLjBdLCBbInQ1NDY1IiwgMS4wXSwgWyJ0NTQ2NiIsIDEuMF0sIFsidDU0NjciLCAxLjBdLCBbInQ1NDY4IiwgMS4wXSwgWyJ0NTQ2OSIsIDEuMF0sIFsidDU0NzAiLCAxLjBdLCBbInQ1NDcxIiwgMS4wXSwgWyJ0NTQ3MiIsIDEuMF0sIFsidDU0NzMiLCAxLjBdLCBbInQ1NDc0IiwgMS4wXSwgWyJ0NTQ3NSIsIDEuMF0sIFsidDU0NzYiLCAxLjBdLCBbInQ1NDc3IiwgMS4wXSwgWyJ0NTQ3OCIsIDEuMF0sIFsidDU0NzkiLCAxLjBdLCBbInQ1NDgwIiwgMS4wXSwgWyJ0NTQ4MSIsIDEuMF0sIFsidDU0ODIiLCAxLjBdLCBbInQ1NDgzIiwgMS4wXSwgWyJ0NTQ4NCIsIDEuMF0sIFsidDU0ODUiLCAxLjBdLCBbInQ1NDg2IiwgMS4wXSwgWyJ0NTQ4NyIsIDEuMF0sIFsidDU0ODgiLCAxLjBdLCBbInQ1NDg5IiwgMS4wXSwgWyJ0NTQ5MCIsIDEuMF0sIFsidDU0OTEiLCAxLjBdLCBbInQ1NDkyIiwgMS4wXSwgWyJ0NTQ5MyIsIDEuMF0sIFsidDU0OTQiLCAxLjBdLCBbInQ1NDk1IiwgMS4wXSwgWyJ0NTQ5NiIsIDEuMF0sIFsidDU0OTciLCAxLjBdLCBbInQ1NDk4IiwgMS4wXSwgWyJ0NTQ5OSIsIDEuMF0sIFsidDU1MDAiLCAxLjBdLCBbInQ1NTAxIiwgMS4wXSwgWyJ0NTUwMiIsIDEuMF0sIFsidDU1MDMiLCAxLjBdLCBbInQ1NTA0IiwgMS4wXSwgWyJ0NTUwNSIsIDEuMF0sIFsidDU1MDYiLCAxLjBdLCBbInQ1NTA3IiwgMS4wXSwgWyJ0NTUwOCIsIDEuMF0sIFsidDU1MDkiLCAxLjBdLCBbInQ1NTEwIiwgMS4wXSwgWyJ0NTUxMSIsIDEuMF0sIFsidDU1MTIiLCAxLjBdLCBbInQ1NTEzIiwgMS4wXSwgWyJ0NTUxNCIsIDEuMF0sIFsidDU1MTUiLCAxLjBdLCBbInQ1NTE2IiwgMS4wXSwgWyJ0NTUxNyIsIDEuMF0sIFsidDU1MTgiLCAxLjBdLCBbInQ1NTE5IiwgMS4wXSwgWyJ0NTUyMCIsIDEuMF0sIFsidDU1MjEiLCAxLjBdLCBbInQ1NTIyIiwgMS4wXSwgWyJ0NTUyMyIsIDEuMF0sIFsidDU1MjQiLCAxLjBdLCBbInQ1NTI1IiwgMS4wXSwgWyJ0NTUyNiIsIDEuMF0sIFsidDU1MjciLCAxLjBdLCBbInQ1NTI4IiwgMS4wXSwgWyJ0NTUyOSIsIDEuMF0sIFsidDU1MzAiLCAxLjBdLCBbInQ1NTMxIiwgMS4wXSwgWyJ0NTUzMiIsIDEuMF0sIFsidDU1MzMiLCAxLjBdLCBbInQ1NTM0IiwgMS4wXSwgWyJ0NTUzNSIsIDEuMF0sIFsidDU1MzYiLCAxLjBdLCBbInQ1NTM3IiwgMS4wXSwgWyJ0NTUzOCIsIDEuMF0sIFsidDU1MzkiLCAxLjBdLCBbInQ1NTQwIiwgMS4wXSwgWyJ0NTU0MSIsIDEuMF0sIFsidDU1NDIiLCAxLjBdLCBbInQ1NTQzIiwgMS4wXSwgWyJ0NTU0NCIsIDEuMF0sIFsidDU1NDUiLCAxLjBdLCBbInQ1NTQ2IiwgMS4wXSwgWyJ0NTU0NyIsIDEuMF0sIFsidDU1NDgiLCAxLjBdLCBbInQ1NTQ5IiwgMS4wXSwgWyJ0NTU1MCIsIDEuMF0sIFsidDU1NTEiLCAxLjBdLCBbInQ1NTUyIiwgMS4wXSwgWyJ0NTU1MyIsIDEuMF0sIFsidDU1NTQiLCAxLjBdLCBbInQ1NTU1IiwgMS4wXSwgWyJ0NTU1NiIsIDEuMF0sIFsidDU1NTciLCAxLjBdLCBbInQ1NTU4IiwgMS4wXSwgWyJ0NTU1OSIsIDEuMF0sIFsidDU1NjAiLCAxLjBdLCBbInQ1NTYxIiwgMS4wXSwgWyJ0NTU2MiIsIDEuMF0sIFsidDU1NjMiLCAxLjBdLCBbInQ1NTY0IiwgMS4wXSwgWyJ0NTU2NSIsIDEuMF0sIFsidDU1NjYiLCAxLjBdLCBbInQ1NTY3IiwgMS4wXSwgWyJ0NTU2OCIsIDEuMF0sIFsidDU1NjkiLCAxLjBdLCBbInQ1NTcwIiwgMS4wXSwgWyJ0NTU3MSIsIDEuMF0sIFsidDU1NzIiLCAxLjBdLCBbInQ1NTczIiwgMS4wXSwgWyJ0NTU3NCIsIDEuMF0sIFsidDU1NzUiLCAxLjBdLCBbInQ1NTc2IiwgMS4wXSwgWyJ0NTU3NyIsIDEuMF0sIFsidDU1NzgiLCAxLjBdLCBbInQ1NTc5IiwgMS4wXSwgWyJ0NTU4MCIsIDEuMF0sIFsidDU1ODEiLCAxLjBdLCBbInQ1NTgyIiwgMS4wXSwgWyJ0NTU4MyIsIDEuMF0sIFsidDU1ODQiLCAxLjBdLCBbInQ1NTg1IiwgMS4wXSwgWyJ0NTU4NiIsIDEuMF0sIFsidDU1ODciLCAxLjBdLCBbInQ1NTg4IiwgMS4wXSwgWyJ0NTU4OSIsIDEuMF0sIFsidDU1OTAiLCAxLjBdLCBbInQ1NTkxIiwgMS4wXSwgWyJ0NTU5MiIsIDEuMF0sIFsidDU1OTMiLCAxLjBdLCBbInQ1NTk0IiwgMS4wXSwgWyJ0NTU5NSIsIDEuMF0sIFsidDU1OTYiLCAxLjBdLCBbInQ1NTk3IiwgMS4wXSwgWyJ0NTU5OCIsIDEuMF0sIFsidDU1OTkiLCAxLjBdLCBbInQ1NjAwIiwgMS4wXSwgWyJ0NTYwMSIsIDEuMF0sIFsidDU2MDIiLCAxLjBdLCBbInQ1NjAzIiwgMS4wXSwgWyJ0NTYwNCIsIDEuMF0sIFsidDU2MDUiLCAxLjBdLCBbInQ1NjA2IiwgMS4wXSwgWyJ0NTYwNyIsIDEuMF0sIFsidDU2MDgiLCAxLjBdLCBbInQ1NjA5IiwgMS4wXSwgWyJ0NTYxMCIsIDEuMF0sIFsidDU2MTEiLCAxLjBdLCBbInQ1NjEyIiwgMS4wXSwgWyJ0NTYxMyIsIDEuMF0sIFsidDU2MTQiLCAxLjBdLCBbInQ1NjE1IiwgMS4wXSwgWyJ0NTYxNiIsIDEuMF0sIFsidDU2MTciLCAxLjBdLCBbInQ1NjE4IiwgMS4wXSwgWyJ0NTYxOSIsIDEuMF0sIFsidDU2MjAiLCAxLjBdLCBbInQ1NjIxIiwgMS4wXSwgWyJ0NTYyMiIsIDEuMF0sIFsidDU2MjMiLCAxLjBdLCBbInQ1NjI0IiwgMS4wXSwgWyJ0NTYyNSIsIDEuMF0sIFsidDU2MjYiLCAxLjBdLCBbInQ1NjI3IiwgMS4wXSwgWyJ0NTYyOCIsIDEuMF0sIFsidDU2MjkiLCAxLjBdLCBbInQ1NjMwIiwgMS4wXSwgWyJ0NTYzMSIsIDEuMF0sIFsidDU2MzIiLCAxLjBdLCBbInQ1NjMzIiwgMS4wXSwgWyJ0NTYzNCIsIDEuMF0sIFsidDU2MzUiLCAxLjBdLCBbInQ1NjM2IiwgMS4wXSwgWyJ0NTYzNyIsIDEuMF0sIFsidDU2MzgiLCAxLjBdLCBbInQ1NjM5IiwgMS4wXSwgWyJ0NTY0MCIsIDEuMF0sIFsidDU2NDEiLCAxLjBdLCBbInQ1NjQyIiwgMS4wXSwgWyJ0NTY0MyIsIDEuMF0sIFsidDU2NDQiLCAxLjBdLCBbInQ1NjQ1IiwgMS4wXSwgWyJ0NTY0NiIsIDEuMF0sIFsidDU2NDciLCAxLjBdLCBbInQ1NjQ4IiwgMS4wXSwgWyJ0NTY0OSIsIDEuMF0sIFsidDU2NTAiLCAxLjBdLCBbInQ1NjUxIiwgMS4wXSwgWyJ0NTY1MiIsIDEuMF0sIFsidDU2NTMiLCAxLjBdLCBbInQ1NjU0IiwgMS4wXSwgWyJ0NTY1NSIsIDEuMF0sIFsidDU2NTYiLCAxLjBdLCBbInQ1NjU3IiwgMS4wXSwgWyJ0NTY1OCIsIDEuMF0sIFsidDU2NTkiLCAxLjBdLCBbInQ1NjYwIiwgMS4wXSwgWyJ0NTY2MSIsIDEuMF0sIFsidDU2NjIiLCAxLjBdLCBbInQ1NjYzIiwgMS4wXSwgWyJ0NTY2NCIsIDEuMF0sIFsidDU2NjUiLCAxLjBdLCBbInQ1NjY2IiwgMS4wXSwgWyJ0NTY2NyIsIDEuMF0sIFsidDU2NjgiLCAxLjBdLCBbInQ1NjY5IiwgMS4wXSwgWyJ0NTY3MCIsIDEuMF0sIFsidDU2NzEiLCAxLjBdLCBbInQ1NjcyIiwgMS4wXSwgWyJ0NTY3MyIsIDEuMF0sIFsidDU2NzQiLCAxLjBdLCBbInQ1Njc1IiwgMS4wXSwgWyJ0NTY3NiIsIDEuMF0sIFsidDU2NzciLCAxLjBdLCBbInQ1Njc4IiwgMS4wXSwgWyJ0NTY3OSIsIDEuMF0sIFsidDU2ODAiLCAxLjBdLCBbInQ1NjgxIiwgMS4wXSwgWyJ0NTY4MiIsIDEuMF0sIFsidDU2ODMiLCAxLjBdLCBbInQ1Njg0IiwgMS4wXSwgWyJ0NTY4NSIsIDEuMF0sIFsidDU2ODYiLCAxLjBdLCBbInQ1Njg3IiwgMS4wXSwgWyJ0NTY4OCIsIDEuMF0sIFsidDU2ODkiLCAxLjBdLCBbInQ1NjkwIiwgMS4wXSwgWyJ0NTY5MSIsIDEuMF0sIFsidDU2OTIiLCAxLjBdLCBbInQ1NjkzIiwgMS4wXSwgWyJ0NTY5NCIsIDEuMF0sIFsidDU2OTUiLCAxLjBdLCBbInQ1Njk2IiwgMS4wXSwgWyJ0NTY5NyIsIDEuMF0sIFsidDU2OTgiLCAxLjBdLCBbInQ1Njk5IiwgMS4wXSwgWyJ0NTcwMCIsIDEuMF0sIFsidDU3MDEiLCAxLjBdLCBbInQ1NzAyIiwgMS4wXSwgWyJ0NTcwMyIsIDEuMF0sIFsidDU3MDQiLCAxLjBdLCBbInQ1NzA1IiwgMS4wXSwgWyJ0NTcwNiIsIDEuMF0sIFsidDU3MDciLCAxLjBdLCBbInQ1NzA4IiwgMS4wXSwgWyJ0NTcwOSIsIDEuMF0sIFsidDU3MTAiLCAxLjBdLCBbInQ1NzExIiwgMS4wXSwgWyJ0NTcxMiIsIDEuMF0sIFsidDU3MTMiLCAxLjBdLCBbInQ1NzE0IiwgMS4wXSwgWyJ0NTcxNSIsIDEuMF0sIFsidDU3MTYiLCAxLjBdLCBbInQ1NzE3IiwgMS4wXSwgWyJ0NTcxOCIsIDEuMF0sIFsidDU3MTkiLCAxLjBdLCBbInQ1NzIwIiwgMS4wXSwgWyJ0NTcyMSIsIDEuMF0sIFsidDU3MjIiLCAxLjBdLCBbInQ1NzIzIiwgMS4wXSwgWyJ0NTcyNCIsIDEuMF0sIFsidDU3MjUiLCAxLjBdLCBbInQ1NzI2IiwgMS4wXSwgWyJ0NTcyNyIsIDEuMF0sIFsidDU3MjgiLCAxLjBdLCBbInQ1NzI5IiwgMS4wXSwgWyJ0NTczMCIsIDEuMF0sIFsidDU3MzEiLCAxLjBdLCBbInQ1NzMyIiwgMS4wXSwgWyJ0NTczMyIsIDEuMF0sIFsidDU3MzQiLCAxLjBdLCBbInQ1NzM1IiwgMS4wXSwgWyJ0NTczNiIsIDEuMF0sIFsidDU3MzciLCAxLjBdLCBbInQ1NzM4IiwgMS4wXSwgWyJ0NTczOSIsIDEuMF0sIFsidDU3NDAiLCAxLjBdLCBbInQ1NzQxIiwgMS4wXSwgWyJ0NTc0MiIsIDEuMF0sIFsidDU3NDMiLCAxLjBdLCBbInQ1NzQ0IiwgMS4wXSwgWyJ0NTc0NSIsIDEuMF0sIFsidDU3NDYiLCAxLjBdLCBbInQ1NzQ3IiwgMS4wXSwgWyJ0NTc0OCIsIDEuMF0sIFsidDU3NDkiLCAxLjBdLCBbInQ1NzUwIiwgMS4wXSwgWyJ0NTc1MSIsIDEuMF0sIFsidDU3NTIiLCAxLjBdLCBbInQ1NzUzIiwgMS4wXSwgWyJ0NTc1NCIsIDEuMF0sIFsidDU3NTUiLCAxLjBdLCBbInQ1NzU2IiwgMS4wXSwgWyJ0NTc1NyIsIDEuMF0sIFsidDU3NTgiLCAxLjBdLCBbInQ1NzU5IiwgMS4wXSwgWyJ0NTc2MCIsIDEuMF0sIFsidDU3NjEiLCAxLjBdLCBbInQ1NzYyIiwgMS4wXSwgWyJ0NTc2MyIsIDEuMF0sIFsidDU3NjQiLCAxLjBdLCBbInQ1NzY1IiwgMS4wXSwgWyJ0NTc2NiIsIDEuMF0sIFsidDU3NjciLCAxLjBdLCBbInQ1NzY4IiwgMS4wXSwgWyJ0NTc2OSIsIDEuMF0sIFsidDU3NzAiLCAxLjBdLCBbInQ1NzcxIiwgMS4wXSwgWyJ0NTc3MiIsIDEuMF0sIFsidDU3NzMiLCAxLjBdLCBbInQ1Nzc0IiwgMS4wXSwgWyJ0NTc3NSIsIDEuMF0sIFsidDU3NzYiLCAxLjBdLCBbInQ1Nzc3IiwgMS4wXSwgWyJ0NTc3OCIsIDEuMF0sIFsidDU3NzkiLCAxLjBdLCBbInQ1NzgwIiwgMS4wXSwgWyJ0NTc4MSIsIDEuMF0sIFsidDU3ODIiLCAxLjBdLCBbInQ1NzgzIiwgMS4wXSwgWyJ0NTc4NCIsIDEuMF0sIFsidDU3ODUiLCAxLjBdLCBbInQ1Nzg2IiwgMS4wXSwgWyJ0NTc4NyIsIDEuMF0sIFsidDU3ODgiLCAxLjBdLCBbInQ1Nzg5IiwgMS4wXSwgWyJ0NTc5MCIsIDEuMF0sIFsidDU3OTEiLCAxLjBdLCBbInQ1NzkyIiwgMS4wXSwgWyJ0NTc5MyIsIDEuMF0sIFsidDU3OTQiLCAxLjBdLCBbInQ1Nzk1IiwgMS4wXSwgWyJ0NTc5NiIsIDEuMF0sIFsidDU3OTciLCAxLjBdLCBbInQ1Nzk4IiwgMS4wXSwgWyJ0NTc5OSIsIDEuMF0sIFsidDU4MDAiLCAxLjBdLCBbInQ1ODAxIiwgMS4wXSwgWyJ0NTgwMiIsIDEuMF0sIFsidDU4MDMiLCAxLjBdLCBbInQ1ODA0IiwgMS4wXSwgWyJ0NTgwNSIsIDEuMF0sIFsidDU4MDYiLCAxLjBdLCBbInQ1ODA3IiwgMS4wXSwgWyJ0NTgwOCIsIDEuMF0sIFsidDU4MDkiLCAxLjBdLCBbInQ1ODEwIiwgMS4wXSwgWyJ0NTgxMSIsIDEuMF0sIFsidDU4MTIiLCAxLjBdLCBbInQ1ODEzIiwgMS4wXSwgWyJ0NTgxNCIsIDEuMF0sIFsidDU4MTUiLCAxLjBdLCBbInQ1ODE2IiwgMS4wXSwgWyJ0NTgxNyIsIDEuMF0sIFsidDU4MTgiLCAxLjBdLCBbInQ1ODE5IiwgMS4wXSwgWyJ0NTgyMCIsIDEuMF0sIFsidDU4MjEiLCAxLjBdLCBbInQ1ODIyIiwgMS4wXSwgWyJ0NTgyMyIsIDEuMF0sIFsidDU4MjQiLCAxLjBdLCBbInQ1ODI1IiwgMS4wXSwgWyJ0NTgyNiIsIDEuMF0sIFsidDU4MjciLCAxLjBdLCBbInQ1ODI4IiwgMS4wXSwgWyJ0NTgyOSIsIDEuMF0sIFsidDU4MzAiLCAxLjBdLCBbInQ1ODMxIiwgMS4wXSwgWyJ0NTgzMiIsIDEuMF0sIFsidDU4MzMiLCAxLjBdLCBbInQ1ODM0IiwgMS4wXSwgWyJ0NTgzNSIsIDEuMF0sIFsidDU4MzYiLCAxLjBdLCBbInQ1ODM3IiwgMS4wXSwgWyJ0NTgzOCIsIDEuMF0sIFsidDU4MzkiLCAxLjBdLCBbInQ1ODQwIiwgMS4wXSwgWyJ0NTg0MSIsIDEuMF0sIFsidDU4NDIiLCAxLjBdLCBbInQ1ODQzIiwgMS4wXSwgWyJ0NTg0NCIsIDEuMF0sIFsidDU4NDUiLCAxLjBdLCBbInQ1ODQ2IiwgMS4wXSwgWyJ0NTg0NyIsIDEuMF0sIFsidDU4NDgiLCAxLjBdLCBbInQ1ODQ5IiwgMS4wXSwgWyJ0NTg1MCIsIDEuMF0sIFsidDU4NTEiLCAxLjBdLCBbInQ1ODUyIiwgMS4wXSwgWyJ0NTg1MyIsIDEuMF0sIFsidDU4NTQiLCAxLjBdLCBbInQ1ODU1IiwgMS4wXSwgWyJ0NTg1NiIsIDEuMF0sIFsidDU4NTciLCAxLjBdLCBbInQ1ODU4IiwgMS4wXSwgWyJ0NTg1OSIsIDEuMF0sIFsidDU4NjAiLCAxLjBdLCBbInQ1ODYxIiwgMS4wXSwgWyJ0NTg2MiIsIDEuMF0sIFsidDU4NjMiLCAxLjBdLCBbInQ1ODY0IiwgMS4wXSwgWyJ0NTg2NSIsIDEuMF0sIFsidDU4NjYiLCAxLjBdLCBbInQ1ODY3IiwgMS4wXSwgWyJ0NTg2OCIsIDEuMF0sIFsidDU4NjkiLCAxLjBdLCBbInQ1ODcwIiwgMS4wXSwgWyJ0NTg3MSIsIDEuMF0sIFsidDU4NzIiLCAxLjBdLCBbInQ1ODczIiwgMS4wXSwgWyJ0NTg3NCIsIDEuMF0sIFsidDU4NzUiLCAxLjBdLCBbInQ1ODc2IiwgMS4wXSwgWyJ0NTg3NyIsIDEuMF0sIFsidDU4NzgiLCAxLjBdLCBbInQ1ODc5IiwgMS4wXSwgWyJ0NTg4MCIsIDEuMF0sIFsidDU4ODEiLCAxLjBdLCBbInQ1ODgyIiwgMS4wXSwgWyJ0NTg4MyIsIDEuMF0sIFsidDU4ODQiLCAxLjBdLCBbInQ1ODg1IiwgMS4wXSwgWyJ0NTg4NiIsIDEuMF0sIFsidDU4ODciLCAxLjBdLCBbInQ1ODg4IiwgMS4wXSwgWyJ0NTg4OSIsIDEuMF0sIFsidDU4OTAiLCAxLjBdLCBbInQ1ODkxIiwgMS4wXSwgWyJ0NTg5MiIsIDEuMF0sIFsidDU4OTMiLCAxLjBdLCBbInQ1ODk0IiwgMS4wXSwgWyJ0NTg5NSIsIDEuMF0sIFsidDU4OTYiLCAxLjBdLCBbInQ1ODk3IiwgMS4wXSwgWyJ0NTg5OCIsIDEuMF0sIFsidDU4OTkiLCAxLjBdLCBbInQ1OTAwIiwgMS4wXSwgWyJ0NTkwMSIsIDEuMF0sIFsidDU5MDIiLCAxLjBdLCBbInQ1OTAzIiwgMS4wXSwgWyJ0NTkwNCIsIDEuMF0sIFsidDU5MDUiLCAxLjBdLCBbInQ1OTA2IiwgMS4wXSwgWyJ0NTkwNyIsIDEuMF0sIFsidDU5MDgiLCAxLjBdLCBbInQ1OTA5IiwgMS4wXSwgWyJ0NTkxMCIsIDEuMF0sIFsidDU5MTEiLCAxLjBdLCBbInQ1OTEyIiwgMS4wXSwgWyJ0NTkxMyIsIDEuMF0sIFsidDU5MTQiLCAxLjBdLCBbInQ1OTE1IiwgMS4wXSwgWyJ0NTkxNiIsIDEuMF0sIFsidDU5MTciLCAxLjBdLCBbInQ1OTE4IiwgMS4wXSwgWyJ0NTkxOSIsIDEuMF0sIFsidDU5MjAiLCAxLjBdLCBbInQ1OTIxIiwgMS4wXSwgWyJ0NTkyMiIsIDEuMF0sIFsidDU5MjMiLCAxLjBdLCBbInQ1OTI0IiwgMS4wXSwgWyJ0NTkyNSIsIDEuMF0sIFsidDU5MjYiLCAxLjBdLCBbInQ1OTI3IiwgMS4wXSwgWyJ0NTkyOCIsIDEuMF0sIFsidDU5MjkiLCAxLjBdLCBbInQ1OTMwIiwgMS4wXSwgWyJ0NTkzMSIsIDEuMF0sIFsidDU5MzIiLCAxLjBdLCBbInQ1OTMzIiwgMS4wXSwgWyJ0NTkzNCIsIDEuMF0sIFsidDU5MzUiLCAxLjBdLCBbInQ1OTM2IiwgMS4wXSwgWyJ0NTkzNyIsIDEuMF0sIFsidDU5MzgiLCAxLjBdLCBbInQ1OTM5IiwgMS4wXSwgWyJ0NTk0MCIsIDEuMF0sIFsidDU5NDEiLCAxLjBdLCBbInQ1OTQyIiwgMS4wXSwgWyJ0NTk0MyIsIDEuMF0sIFsidDU5NDQiLCAxLjBdLCBbInQ1OTQ1IiwgMS4wXSwgWyJ0NTk0NiIsIDEuMF0sIFsidDU5NDciLCAxLjBdLCBbInQ1OTQ4IiwgMS4wXSwgWyJ0NTk0OSIsIDEuMF0sIFsidDU5NTAiLCAxLjBdLCBbInQ1OTUxIiwgMS4wXSwgWyJ0NTk1MiIsIDEuMF0sIFsidDU5NTMiLCAxLjBdLCBbInQ1OTU0IiwgMS4wXSwgWyJ0NTk1NSIsIDEuMF0sIFsidDU5NTYiLCAxLjBdLCBbInQ1OTU3IiwgMS4wXSwgWyJ0NTk1OCIsIDEuMF0sIFsidDU5NTkiLCAxLjBdLCBbInQ1OTYwIiwgMS4wXSwgWyJ0NTk2MSIsIDEuMF0sIFsidDU5NjIiLCAxLjBdLCBbInQ1OTYzIiwgMS4wXSwgWyJ0NTk2NCIsIDEuMF0sIFsidDU5NjUiLCAxLjBdLCBbInQ1OTY2IiwgMS4wXSwgWyJ0NTk2NyIsIDEuMF0sIFsidDU5NjgiLCAxLjBdLCBbInQ1OTY5IiwgMS4wXSwgWyJ0NTk3MCIsIDEuMF0sIFsidDU5NzEiLCAxLjBdLCBbInQ1OTcyIiwgMS4wXSwgWyJ0NTk3MyIsIDEuMF0sIFsidDU5NzQiLCAxLjBdLCBbInQ1OTc1IiwgMS4wXSwgWyJ0NTk3NiIsIDEuMF0sIFsidDU5NzciLCAxLjBdLCBbInQ1OTc4IiwgMS4wXSwgWyJ0NTk3OSIsIDEuMF0sIFsidDU5ODAiLCAxLjBdLCBbInQ1OTgxIiwgMS4wXSwgWyJ0NTk4MiIsIDEuMF0sIFsidDU5ODMiLCAxLjBdLCBbInQ1OTg0IiwgMS4wXSwgWyJ0NTk4NSIsIDEuMF0sIFsidDU5ODYiLCAxLjBdLCBbInQ1OTg3IiwgMS4wXSwgWyJ0NTk4OCIsIDEuMF0sIFsidDU5ODkiLCAxLjBdLCBbInQ1OTkwIiwgMS4wXSwgWyJ0NTk5MSIsIDEuMF0sIFsidDU5OTIiLCAxLjBdLCBbInQ1OTkzIiwgMS4wXSwgWyJ0NTk5NCIsIDEuMF0sIFsidDU5OTUiLCAxLjBdLCBbInQ1OTk2IiwgMS4wXSwgWyJ0NTk5NyIsIDEuMF0sIFsidDU5OTgiLCAxLjBdLCBbInQ1OTk5IiwgMS4wXSwgWyJ0NjAwMCIsIDEuMF0sIFsidDYwMDEiLCAxLjBdLCBbInQ2MDAyIiwgMS4wXSwgWyJ0NjAwMyIsIDEuMF0sIFsidDYwMDQiLCAxLjBdLCBbInQ2MDA1IiwgMS4wXSwgWyJ0NjAwNiIsIDEuMF0sIFsidDYwMDciLCAxLjBdLCBbInQ2MDA4IiwgMS4wXSwgWyJ0NjAwOSIsIDEuMF0sIFsidDYwMTAiLCAxLjBdLCBbInQ2MDExIiwgMS4wXSwgWyJ0NjAxMiIsIDEuMF0sIFsidDYwMTMiLCAxLjBdLCBbInQ2MDE0IiwgMS4wXSwgWyJ0NjAxNSIsIDEuMF0sIFsidDYwMTYiLCAxLjBdLCBbInQ2MDE3IiwgMS4wXSwgWyJ0NjAxOCIsIDEuMF0sIFsidDYwMTkiLCAxLjBdLCBbInQ2MDIwIiwgMS4wXSwgWyJ0NjAyMSIsIDEuMF0sIFsidDYwMjIiLCAxLjBdLCBbInQ2MDIzIiwgMS4wXSwgWyJ0NjAyNCIsIDEuMF0sIFsidDYwMjUiLCAxLjBdLCBbInQ2MDI2IiwgMS4wXSwgWyJ0NjAyNyIsIDEuMF0sIFsidDYwMjgiLCAxLjBdLCBbInQ2MDI5IiwgMS4wXSwgWyJ0NjAzMCIsIDEuMF0sIFsidDYwMzEiLCAxLjBdLCBbInQ2MDMyIiwgMS4wXSwgWyJ0NjAzMyIsIDEuMF0sIFsidDYwMzQiLCAxLjBdLCBbInQ2MDM1IiwgMS4wXSwgWyJ0NjAzNiIsIDEuMF0sIFsidDYwMzciLCAxLjBdLCBbInQ2MDM4IiwgMS4wXSwgWyJ0NjAzOSIsIDEuMF0sIFsidDYwNDAiLCAxLjBdLCBbInQ2MDQxIiwgMS4wXSwgWyJ0NjA0MiIsIDEuMF0sIFsidDYwNDMiLCAxLjBdLCBbInQ2MDQ0IiwgMS4wXSwgWyJ0NjA0NSIsIDEuMF0sIFsidDYwNDYiLCAxLjBdLCBbInQ2MDQ3IiwgMS4wXSwgWyJ0NjA0OCIsIDEuMF0sIFsidDYwNDkiLCAxLjBdLCBbInQ2MDUwIiwgMS4wXSwgWyJ0NjA1MSIsIDEuMF0sIFsidDYwNTIiLCAxLjBdLCBbInQ2MDUzIiwgMS4wXSwgWyJ0NjA1NCIsIDEuMF0sIFsidDYwNTUiLCAxLjBdLCBbInQ2MDU2IiwgMS4wXSwgWyJ0NjA1NyIsIDEuMF0sIFsidDYwNTgiLCAxLjBdLCBbInQ2MDU5IiwgMS4wXSwgWyJ0NjA2MCIsIDEuMF0sIFsidDYwNjEiLCAxLjBdLCBbInQ2MDYyIiwgMS4wXSwgWyJ0NjA2MyIsIDEuMF0sIFsidDYwNjQiLCAxLjBdLCBbInQ2MDY1IiwgMS4wXSwgWyJ0NjA2NiIsIDEuMF0sIFsidDYwNjciLCAxLjBdLCBbInQ2MDY4IiwgMS4wXSwgWyJ0NjA2OSIsIDEuMF0sIFsidDYwNzAiLCAxLjBdLCBbInQ2MDcxIiwgMS4wXSwgWyJ0NjA3MiIsIDEuMF0sIFsidDYwNzMiLCAxLjBdLCBbInQ2MDc0IiwgMS4wXSwgWyJ0NjA3NSIsIDEuMF0sIFsidDYwNzYiLCAxLjBdLCBbInQ2MDc3IiwgMS4wXSwgWyJ0NjA3OCIsIDEuMF0sIFsidDYwNzkiLCAxLjBdLCBbInQ2MDgwIiwgMS4wXSwgWyJ0NjA4MSIsIDEuMF0sIFsidDYwODIiLCAxLjBdLCBbInQ2MDgzIiwgMS4wXSwgWyJ0NjA4NCIsIDEuMF0sIFsidDYwODUiLCAxLjBdLCBbInQ2MDg2IiwgMS4wXSwgWyJ0NjA4NyIsIDEuMF0sIFsidDYwODgiLCAxLjBdLCBbInQ2MDg5IiwgMS4wXSwgWyJ0NjA5MCIsIDEuMF0sIFsidDYwOTEiLCAxLjBdLCBbInQ2MDkyIiwgMS4wXSwgWyJ0NjA5MyIsIDEuMF0sIFsidDYwOTQiLCAxLjBdLCBbInQ2MDk1IiwgMS4wXSwgWyJ0NjA5NiIsIDEuMF0sIFsidDYwOTciLCAxLjBdLCBbInQ2MDk4IiwgMS4wXSwgWyJ0NjA5OSIsIDEuMF0sIFsidDYxMDAiLCAxLjBdLCBbInQ2MTAxIiwgMS4wXSwgWyJ0NjEwMiIsIDEuMF0sIFsidDYxMDMiLCAxLjBdLCBbInQ2MTA0IiwgMS4wXSwgWyJ0NjEwNSIsIDEuMF0sIFsidDYxMDYiLCAxLjBdLCBbInQ2MTA3IiwgMS4wXSwgWyJ0NjEwOCIsIDEuMF0sIFsidDYxMDkiLCAxLjBdLCBbInQ2MTEwIiwgMS4wXSwgWyJ0NjExMSIsIDEuMF0sIFsidDYxMTIiLCAxLjBdLCBbInQ2MTEzIiwgMS4wXSwgWyJ0NjExNCIsIDEuMF0sIFsidDYxMTUiLCAxLjBdLCBbInQ2MTE2IiwgMS4wXSwgWyJ0NjExNyIsIDEuMF0sIFsidDYxMTgiLCAxLjBdLCBbInQ2MTE5IiwgMS4wXSwgWyJ0NjEyMCIsIDEuMF0sIFsidDYxMjEiLCAxLjBdLCBbInQ2MTIyIiwgMS4wXSwgWyJ0NjEyMyIsIDEuMF0sIFsidDYxMjQiLCAxLjBdLCBbInQ2MTI1IiwgMS4wXSwgWyJ0NjEyNiIsIDEuMF0sIFsidDYxMjciLCAxLjBdLCBbInQ2MTI4IiwgMS4wXSwgWyJ0NjEyOSIsIDEuMF0sIFsidDYxMzAiLCAxLjBdLCBbInQ2MTMxIiwgMS4wXSwgWyJ0NjEzMiIsIDEuMF0sIFsidDYxMzMiLCAxLjBdLCBbInQ2MTM0IiwgMS4wXSwgWyJ0NjEzNSIsIDEuMF0sIFsidDYxMzYiLCAxLjBdLCBbInQ2MTM3IiwgMS4wXSwgWyJ0NjEzOCIsIDEuMF0sIFsidDYxMzkiLCAxLjBdLCBbInQ2MTQwIiwgMS4wXSwgWyJ0NjE0MSIsIDEuMF0sIFsidDYxNDIiLCAxLjBdLCBbInQ2MTQzIiwgMS4wXSwgWyJ0NjE0NCIsIDEuMF0sIFsidDYxNDUiLCAxLjBdLCBbInQ2MTQ2IiwgMS4wXSwgWyJ0NjE0NyIsIDEuMF0sIFsidDYxNDgiLCAxLjBdLCBbInQ2MTQ5IiwgMS4wXSwgWyJ0NjE1MCIsIDEuMF0sIFsidDYxNTEiLCAxLjBdLCBbInQ2MTUyIiwgMS4wXSwgWyJ0NjE1MyIsIDEuMF0sIFsidDYxNTQiLCAxLjBdLCBbInQ2MTU1IiwgMS4wXSwgWyJ0NjE1NiIsIDEuMF0sIFsidDYxNTciLCAxLjBdLCBbInQ2MTU4IiwgMS4wXSwgWyJ0NjE1OSIsIDEuMF0sIFsidDYxNjAiLCAxLjBdLCBbInQ2MTYxIiwgMS4wXSwgWyJ0NjE2MiIsIDEuMF0sIFsidDYxNjMiLCAxLjBdLCBbInQ2MTY0IiwgMS4wXSwgWyJ0NjE2NSIsIDEuMF0sIFsidDYxNjYiLCAxLjBdLCBbInQ2MTY3IiwgMS4wXSwgWyJ0NjE2OCIsIDEuMF0sIFsidDYxNjkiLCAxLjBdLCBbInQ2MTcwIiwgMS4wXSwgWyJ0NjE3MSIsIDEuMF0sIFsidDYxNzIiLCAxLjBdLCBbInQ2MTczIiwgMS4wXSwgWyJ0NjE3NCIsIDEuMF0sIFsidDYxNzUiLCAxLjBdLCBbInQ2MTc2IiwgMS4wXSwgWyJ0NjE3NyIsIDEuMF0sIFsidDYxNzgiLCAxLjBdLCBbInQ2MTc5IiwgMS4wXSwgWyJ0NjE4MCIsIDEuMF0sIFsidDYxODEiLCAxLjBdLCBbInQ2MTgyIiwgMS4wXSwgWyJ0NjE4MyIsIDEuMF0sIFsidDYxODQiLCAxLjBdLCBbInQ2MTg1IiwgMS4wXSwgWyJ0NjE4NiIsIDEuMF0sIFsidDYxODciLCAxLjBdLCBbInQ2MTg4IiwgMS4wXSwgWyJ0NjE4OSIsIDEuMF0sIFsidDYxOTAiLCAxLjBdLCBbInQ2MTkxIiwgMS4wXSwgWyJ0NjE5MiIsIDEuMF0sIFsidDYxOTMiLCAxLjBdLCBbInQ2MTk0IiwgMS4wXSwgWyJ0NjE5NSIsIDEuMF0sIFsidDYxOTYiLCAxLjBdLCBbInQ2MTk3IiwgMS4wXSwgWyJ0NjE5OCIsIDEuMF0sIFsidDYxOTkiLCAxLjBdLCBbInQ2MjAwIiwgMS4wXSwgWyJ0NjIwMSIsIDEuMF0sIFsidDYyMDIiLCAxLjBdLCBbInQ2MjAzIiwgMS4wXSwgWyJ0NjIwNCIsIDEuMF0sIFsidDYyMDUiLCAxLjBdLCBbInQ2MjA2IiwgMS4wXSwgWyJ0NjIwNyIsIDEuMF0sIFsidDYyMDgiLCAxLjBdLCBbInQ2MjA5IiwgMS4wXSwgWyJ0NjIxMCIsIDEuMF0sIFsidDYyMTEiLCAxLjBdLCBbInQ2MjEyIiwgMS4wXSwgWyJ0NjIxMyIsIDEuMF0sIFsidDYyMTQiLCAxLjBdLCBbInQ2MjE1IiwgMS4wXSwgWyJ0NjIxNiIsIDEuMF0sIFsidDYyMTciLCAxLjBdLCBbInQ2MjE4IiwgMS4wXSwgWyJ0NjIxOSIsIDEuMF0sIFsidDYyMjAiLCAxLjBdLCBbInQ2MjIxIiwgMS4wXSwgWyJ0NjIyMiIsIDEuMF0sIFsidDYyMjMiLCAxLjBdLCBbInQ2MjI0IiwgMS4wXSwgWyJ0NjIyNSIsIDEuMF0sIFsidDYyMjYiLCAxLjBdLCBbInQ2MjI3IiwgMS4wXSwgWyJ0NjIyOCIsIDEuMF0sIFsidDYyMjkiLCAxLjBdLCBbInQ2MjMwIiwgMS4wXSwgWyJ0NjIzMSIsIDEuMF0sIFsidDYyMzIiLCAxLjBdLCBbInQ2MjMzIiwgMS4wXSwgWyJ0NjIzNCIsIDEuMF0sIFsidDYyMzUiLCAxLjBdLCBbInQ2MjM2IiwgMS4wXSwgWyJ0NjIzNyIsIDEuMF0sIFsidDYyMzgiLCAxLjBdLCBbInQ2MjM5IiwgMS4wXSwgWyJ0NjI0MCIsIDEuMF0sIFsidDYyNDEiLCAxLjBdLCBbInQ2MjQyIiwgMS4wXSwgWyJ0NjI0MyIsIDEuMF0sIFsidDYyNDQiLCAxLjBdLCBbInQ2MjQ1IiwgMS4wXSwgWyJ0NjI0NiIsIDEuMF0sIFsidDYyNDciLCAxLjBdLCBbInQ2MjQ4IiwgMS4wXSwgWyJ0NjI0OSIsIDEuMF0sIFsidDYyNTAiLCAxLjBdLCBbInQ2MjUxIiwgMS4wXSwgWyJ0NjI1MiIsIDEuMF0sIFsidDYyNTMiLCAxLjBdLCBbInQ2MjU0IiwgMS4wXSwgWyJ0NjI1NSIsIDEuMF0sIFsidDYyNTYiLCAxLjBdLCBbInQ2MjU3IiwgMS4wXSwgWyJ0NjI1OCIsIDEuMF0sIFsidDYyNTkiLCAxLjBdLCBbInQ2MjYwIiwgMS4wXSwgWyJ0NjI2MSIsIDEuMF0sIFsidDYyNjIiLCAxLjBdLCBbInQ2MjYzIiwgMS4wXSwgWyJ0NjI2NCIsIDEuMF0sIFsidDYyNjUiLCAxLjBdLCBbInQ2MjY2IiwgMS4wXSwgWyJ0NjI2NyIsIDEuMF0sIFsidDYyNjgiLCAxLjBdLCBbInQ2MjY5IiwgMS4wXSwgWyJ0NjI3MCIsIDEuMF0sIFsidDYyNzEiLCAxLjBdLCBbInQ2MjcyIiwgMS4wXSwgWyJ0NjI3MyIsIDEuMF0sIFsidDYyNzQiLCAxLjBdLCBbInQ2Mjc1IiwgMS4wXSwgWyJ0NjI3NiIsIDEuMF0sIFsidDYyNzciLCAxLjBdLCBbInQ2Mjc4IiwgMS4wXSwgWyJ0NjI3OSIsIDEuMF0sIFsidDYyODAiLCAxLjBdLCBbInQ2MjgxIiwgMS4wXSwgWyJ0NjI4MiIsIDEuMF0sIFsidDYyODMiLCAxLjBdLCBbInQ2Mjg0IiwgMS4wXSwgWyJ0NjI4NSIsIDEuMF0sIFsidDYyODYiLCAxLjBdLCBbInQ2Mjg3IiwgMS4wXSwgWyJ0NjI4OCIsIDEuMF0sIFsidDYyODkiLCAxLjBdLCBbInQ2MjkwIiwgMS4wXSwgWyJ0NjI5MSIsIDEuMF0sIFsidDYyOTIiLCAxLjBdLCBbInQ2MjkzIiwgMS4wXSwgWyJ0NjI5NCIsIDEuMF0sIFsidDYyOTUiLCAxLjBdLCBbInQ2Mjk2IiwgMS4wXSwgWyJ0NjI5NyIsIDEuMF0sIFsidDYyOTgiLCAxLjBdLCBbInQ2Mjk5IiwgMS4wXSwgWyJ0NjMwMCIsIDEuMF0sIFsidDYzMDEiLCAxLjBdLCBbInQ2MzAyIiwgMS4wXSwgWyJ0NjMwMyIsIDEuMF0sIFsidDYzMDQiLCAxLjBdLCBbInQ2MzA1IiwgMS4wXSwgWyJ0NjMwNiIsIDEuMF0sIFsidDYzMDciLCAxLjBdLCBbInQ2MzA4IiwgMS4wXSwgWyJ0NjMwOSIsIDEuMF0sIFsidDYzMTAiLCAxLjBdLCBbInQ2MzExIiwgMS4wXSwgWyJ0NjMxMiIsIDEuMF0sIFsidDYzMTMiLCAxLjBdLCBbInQ2MzE0IiwgMS4wXSwgWyJ0NjMxNSIsIDEuMF0sIFsidDYzMTYiLCAxLjBdLCBbInQ2MzE3IiwgMS4wXSwgWyJ0NjMxOCIsIDEuMF0sIFsidDYzMTkiLCAxLjBdLCBbInQ2MzIwIiwgMS4wXSwgWyJ0NjMyMSIsIDEuMF0sIFsidDYzMjIiLCAxLjBdLCBbInQ2MzIzIiwgMS4wXSwgWyJ0NjMyNCIsIDEuMF0sIFsidDYzMjUiLCAxLjBdLCBbInQ2MzI2IiwgMS4wXSwgWyJ0NjMyNyIsIDEuMF0sIFsidDYzMjgiLCAxLjBdLCBbInQ2MzI5IiwgMS4wXSwgWyJ0NjMzMCIsIDEuMF0sIFsidDYzMzEiLCAxLjBdLCBbInQ2MzMyIiwgMS4wXSwgWyJ0NjMzMyIsIDEuMF0sIFsidDYzMzQiLCAxLjBdLCBbInQ2MzM1IiwgMS4wXSwgWyJ0NjMzNiIsIDEuMF0sIFsidDYzMzciLCAxLjBdLCBbInQ2MzM4IiwgMS4wXSwgWyJ0NjMzOSIsIDEuMF0sIFsidDYzNDAiLCAxLjBdLCBbInQ2MzQxIiwgMS4wXSwgWyJ0NjM0MiIsIDEuMF0sIFsidDYzNDMiLCAxLjBdLCBbInQ2MzQ0IiwgMS4wXSwgWyJ0NjM0NSIsIDEuMF0sIFsidDYzNDYiLCAxLjBdLCBbInQ2MzQ3IiwgMS4wXSwgWyJ0NjM0OCIsIDEuMF0sIFsidDYzNDkiLCAxLjBdLCBbInQ2MzUwIiwgMS4wXSwgWyJ0NjM1MSIsIDEuMF0sIFsidDYzNTIiLCAxLjBdLCBbInQ2MzUzIiwgMS4wXSwgWyJ0NjM1NCIsIDEuMF0sIFsidDYzNTUiLCAxLjBdLCBbInQ2MzU2IiwgMS4wXSwgWyJ0NjM1NyIsIDEuMF0sIFsidDYzNTgiLCAxLjBdLCBbInQ2MzU5IiwgMS4wXSwgWyJ0NjM2MCIsIDEuMF0sIFsidDYzNjEiLCAxLjBdLCBbInQ2MzYyIiwgMS4wXSwgWyJ0NjM2MyIsIDEuMF0sIFsidDYzNjQiLCAxLjBdLCBbInQ2MzY1IiwgMS4wXSwgWyJ0NjM2NiIsIDEuMF0sIFsidDYzNjciLCAxLjBdLCBbInQ2MzY4IiwgMS4wXSwgWyJ0NjM2OSIsIDEuMF0sIFsidDYzNzAiLCAxLjBdLCBbInQ2MzcxIiwgMS4wXSwgWyJ0NjM3MiIsIDEuMF0sIFsidDYzNzMiLCAxLjBdLCBbInQ2Mzc0IiwgMS4wXSwgWyJ0NjM3NSIsIDEuMF0sIFsidDYzNzYiLCAxLjBdLCBbInQ2Mzc3IiwgMS4wXSwgWyJ0NjM3OCIsIDEuMF0sIFsidDYzNzkiLCAxLjBdLCBbInQ2MzgwIiwgMS4wXSwgWyJ0NjM4MSIsIDEuMF0sIFsidDYzODIiLCAxLjBdLCBbInQ2MzgzIiwgMS4wXSwgWyJ0NjM4NCIsIDEuMF0sIFsidDYzODUiLCAxLjBdLCBbInQ2Mzg2IiwgMS4wXSwgWyJ0NjM4NyIsIDEuMF0sIFsidDYzODgiLCAxLjBdLCBbInQ2Mzg5IiwgMS4wXSwgWyJ0NjM5MCIsIDEuMF0sIFsidDYzOTEiLCAxLjBdLCBbInQ2MzkyIiwgMS4wXSwgWyJ0NjM5MyIsIDEuMF0sIFsidDYzOTQiLCAxLjBdLCBbInQ2Mzk1IiwgMS4wXSwgWyJ0NjM5NiIsIDEuMF0sIFsidDYzOTciLCAxLjBdLCBbInQ2Mzk4IiwgMS4wXSwgWyJ0NjM5OSIsIDEuMF0sIFsidDY0MDAiLCAxLjBdLCBbInQ2NDAxIiwgMS4wXSwgWyJ0NjQwMiIsIDEuMF0sIFsidDY0MDMiLCAxLjBdLCBbInQ2NDA0IiwgMS4wXSwgWyJ0NjQwNSIsIDEuMF0sIFsidDY0MDYiLCAxLjBdLCBbInQ2NDA3IiwgMS4wXSwgWyJ0NjQwOCIsIDEuMF0sIFsidDY0MDkiLCAxLjBdLCBbInQ2NDEwIiwgMS4wXSwgWyJ0NjQxMSIsIDEuMF0sIFsidDY0MTIiLCAxLjBdLCBbInQ2NDEzIiwgMS4wXSwgWyJ0NjQxNCIsIDEuMF0sIFsidDY0MTUiLCAxLjBdLCBbInQ2NDE2IiwgMS4wXSwgWyJ0NjQxNyIsIDEuMF0sIFsidDY0MTgiLCAxLjBdLCBbInQ2NDE5IiwgMS4wXSwgWyJ0NjQyMCIsIDEuMF0sIFsidDY0MjEiLCAxLjBdLCBbInQ2NDIyIiwgMS4wXSwgWyJ0NjQyMyIsIDEuMF0sIFsidDY0MjQiLCAxLjBdLCBbInQ2NDI1IiwgMS4wXSwgWyJ0NjQyNiIsIDEuMF0sIFsidDY0MjciLCAxLjBdLCBbInQ2NDI4IiwgMS4wXSwgWyJ0NjQyOSIsIDEuMF0sIFsidDY0MzAiLCAxLjBdLCBbInQ2NDMxIiwgMS4wXSwgWyJ0NjQzMiIsIDEuMF0sIFsidDY0MzMiLCAxLjBdLCBbInQ2NDM0IiwgMS4wXSwgWyJ0NjQzNSIsIDEuMF0sIFsidDY0MzYiLCAxLjBdLCBbInQ2NDM3IiwgMS4wXSwgWyJ0NjQzOCIsIDEuMF0sIFsidDY0MzkiLCAxLjBdLCBbInQ2NDQwIiwgMS4wXSwgWyJ0NjQ0MSIsIDEuMF0sIFsidDY0NDIiLCAxLjBdLCBbInQ2NDQzIiwgMS4wXSwgWyJ0NjQ0NCIsIDEuMF0sIFsidDY0NDUiLCAxLjBdLCBbInQ2NDQ2IiwgMS4wXSwgWyJ0NjQ0NyIsIDEuMF0sIFsidDY0NDgiLCAxLjBdLCBbInQ2NDQ5IiwgMS4wXSwgWyJ0NjQ1MCIsIDEuMF0sIFsidDY0NTEiLCAxLjBdLCBbInQ2NDUyIiwgMS4wXSwgWyJ0NjQ1MyIsIDEuMF0sIFsidDY0NTQiLCAxLjBdLCBbInQ2NDU1IiwgMS4wXSwgWyJ0NjQ1NiIsIDEuMF0sIFsidDY0NTciLCAxLjBdLCBbInQ2NDU4IiwgMS4wXSwgWyJ0NjQ1OSIsIDEuMF0sIFsidDY0NjAiLCAxLjBdLCBbInQ2NDYxIiwgMS4wXSwgWyJ0NjQ2MiIsIDEuMF0sIFsidDY0NjMiLCAxLjBdLCBbInQ2NDY0IiwgMS4wXSwgWyJ0NjQ2NSIsIDEuMF0sIFsidDY0NjYiLCAxLjBdLCBbInQ2NDY3IiwgMS4wXSwgWyJ0NjQ2OCIsIDEuMF0sIFsidDY0NjkiLCAxLjBdLCBbInQ2NDcwIiwgMS4wXSwgWyJ0NjQ3MSIsIDEuMF0sIFsidDY0NzIiLCAxLjBdLCBbInQ2NDczIiwgMS4wXSwgWyJ0NjQ3NCIsIDEuMF0sIFsidDY0NzUiLCAxLjBdLCBbInQ2NDc2IiwgMS4wXSwgWyJ0NjQ3NyIsIDEuMF0sIFsidDY0NzgiLCAxLjBdLCBbInQ2NDc5IiwgMS4wXSwgWyJ0NjQ4MCIsIDEuMF0sIFsidDY0ODEiLCAxLjBdLCBbInQ2NDgyIiwgMS4wXSwgWyJ0NjQ4MyIsIDEuMF0sIFsidDY0ODQiLCAxLjBdLCBbInQ2NDg1IiwgMS4wXSwgWyJ0NjQ4NiIsIDEuMF0sIFsidDY0ODciLCAxLjBdLCBbInQ2NDg4IiwgMS4wXSwgWyJ0NjQ4OSIsIDEuMF0sIFsidDY0OTAiLCAxLjBdLCBbInQ2NDkxIiwgMS4wXSwgWyJ0NjQ5MiIsIDEuMF0sIFsidDY0OTMiLCAxLjBdLCBbInQ2NDk0IiwgMS4wXSwgWyJ0NjQ5NSIsIDEuMF0sIFsidDY0OTYiLCAxLjBdLCBbInQ2NDk3IiwgMS4wXSwgWyJ0NjQ5OCIsIDEuMF0sIFsidDY0OTkiLCAxLjBdLCBbInQ2NTAwIiwgMS4wXSwgWyJ0NjUwMSIsIDEuMF0sIFsidDY1MDIiLCAxLjBdLCBbInQ2NTAzIiwgMS4wXSwgWyJ0NjUwNCIsIDEuMF0sIFsidDY1MDUiLCAxLjBdLCBbInQ2NTA2IiwgMS4wXSwgWyJ0NjUwNyIsIDEuMF0sIFsidDY1MDgiLCAxLjBdLCBbInQ2NTA5IiwgMS4wXSwgWyJ0NjUxMCIsIDEuMF0sIFsidDY1MTEiLCAxLjBdLCBbInQ2NTEyIiwgMS4wXSwgWyJ0NjUxMyIsIDEuMF0sIFsidDY1MTQiLCAxLjBdLCBbInQ2NTE1IiwgMS4wXSwgWyJ0NjUxNiIsIDEuMF0sIFsidDY1MTciLCAxLjBdLCBbInQ2NTE4IiwgMS4wXSwgWyJ0NjUxOSIsIDEuMF0sIFsidDY1MjAiLCAxLjBdLCBbInQ2NTIxIiwgMS4wXSwgWyJ0NjUyMiIsIDEuMF0sIFsidDY1MjMiLCAxLjBdLCBbInQ2NTI0IiwgMS4wXSwgWyJ0NjUyNSIsIDEuMF0sIFsidDY1MjYiLCAxLjBdLCBbInQ2NTI3IiwgMS4wXSwgWyJ0NjUyOCIsIDEuMF0sIFsidDY1MjkiLCAxLjBdLCBbInQ2NTMwIiwgMS4wXSwgWyJ0NjUzMSIsIDEuMF0sIFsidDY1MzIiLCAxLjBdLCBbInQ2NTMzIiwgMS4wXSwgWyJ0NjUzNCIsIDEuMF0sIFsidDY1MzUiLCAxLjBdLCBbInQ2NTM2IiwgMS4wXSwgWyJ0NjUzNyIsIDEuMF0sIFsidDY1MzgiLCAxLjBdLCBbInQ2NTM5IiwgMS4wXSwgWyJ0NjU0MCIsIDEuMF0sIFsidDY1NDEiLCAxLjBdLCBbInQ2NTQyIiwgMS4wXSwgWyJ0NjU0MyIsIDEuMF0sIFsidDY1NDQiLCAxLjBdLCBbInQ2NTQ1IiwgMS4wXSwgWyJ0NjU0NiIsIDEuMF0sIFsidDY1NDciLCAxLjBdLCBbInQ2NTQ4IiwgMS4wXSwgWyJ0NjU0OSIsIDEuMF0sIFsidDY1NTAiLCAxLjBdLCBbInQ2NTUxIiwgMS4wXSwgWyJ0NjU1MiIsIDEuMF0sIFsidDY1NTMiLCAxLjBdLCBbInQ2NTU0IiwgMS4wXSwgWyJ0NjU1NSIsIDEuMF0sIFsidDY1NTYiLCAxLjBdLCBbInQ2NTU3IiwgMS4wXSwgWyJ0NjU1OCIsIDEuMF0sIFsidDY1NTkiLCAxLjBdLCBbInQ2NTYwIiwgMS4wXSwgWyJ0NjU2MSIsIDEuMF0sIFsidDY1NjIiLCAxLjBdLCBbInQ2NTYzIiwgMS4wXSwgWyJ0NjU2NCIsIDEuMF0sIFsidDY1NjUiLCAxLjBdLCBbInQ2NTY2IiwgMS4wXSwgWyJ0NjU2NyIsIDEuMF0sIFsidDY1NjgiLCAxLjBdLCBbInQ2NTY5IiwgMS4wXSwgWyJ0NjU3MCIsIDEuMF0sIFsidDY1NzEiLCAxLjBdLCBbInQ2NTcyIiwgMS4wXSwgWyJ0NjU3MyIsIDEuMF0sIFsidDY1NzQiLCAxLjBdLCBbInQ2NTc1IiwgMS4wXSwgWyJ0NjU3NiIsIDEuMF0sIFsidDY1NzciLCAxLjBdLCBbInQ2NTc4IiwgMS4wXSwgWyJ0NjU3OSIsIDEuMF0sIFsidDY1ODAiLCAxLjBdLCBbInQ2NTgxIiwgMS4wXSwgWyJ0NjU4MiIsIDEuMF0sIFsidDY1ODMiLCAxLjBdLCBbInQ2NTg0IiwgMS4wXSwgWyJ0NjU4NSIsIDEuMF0sIFsidDY1ODYiLCAxLjBdLCBbInQ2NTg3IiwgMS4wXSwgWyJ0NjU4OCIsIDEuMF0sIFsidDY1ODkiLCAxLjBdLCBbInQ2NTkwIiwgMS4wXSwgWyJ0NjU5MSIsIDEuMF0sIFsidDY1OTIiLCAxLjBdLCBbInQ2NTkzIiwgMS4wXSwgWyJ0NjU5NCIsIDEuMF0sIFsidDY1OTUiLCAxLjBdLCBbInQ2NTk2IiwgMS4wXSwgWyJ0NjU5NyIsIDEuMF0sIFsidDY1OTgiLCAxLjBdLCBbInQ2NTk5IiwgMS4wXSwgWyJ0NjYwMCIsIDEuMF0sIFsidDY2MDEiLCAxLjBdLCBbInQ2NjAyIiwgMS4wXSwgWyJ0NjYwMyIsIDEuMF0sIFsidDY2MDQiLCAxLjBdLCBbInQ2NjA1IiwgMS4wXSwgWyJ0NjYwNiIsIDEuMF0sIFsidDY2MDciLCAxLjBdLCBbInQ2NjA4IiwgMS4wXSwgWyJ0NjYwOSIsIDEuMF0sIFsidDY2MTAiLCAxLjBdLCBbInQ2NjExIiwgMS4wXSwgWyJ0NjYxMiIsIDEuMF0sIFsidDY2MTMiLCAxLjBdLCBbInQ2NjE0IiwgMS4wXSwgWyJ0NjYxNSIsIDEuMF0sIFsidDY2MTYiLCAxLjBdLCBbInQ2NjE3IiwgMS4wXSwgWyJ0NjYxOCIsIDEuMF0sIFsidDY2MTkiLCAxLjBdLCBbInQ2NjIwIiwgMS4wXSwgWyJ0NjYyMSIsIDEuMF0sIFsidDY2MjIiLCAxLjBdLCBbInQ2NjIzIiwgMS4wXSwgWyJ0NjYyNCIsIDEuMF0sIFsidDY2MjUiLCAxLjBdLCBbInQ2NjI2IiwgMS4wXSwgWyJ0NjYyNyIsIDEuMF0sIFsidDY2MjgiLCAxLjBdLCBbInQ2NjI5IiwgMS4wXSwgWyJ0NjYzMCIsIDEuMF0sIFsidDY2MzEiLCAxLjBdLCBbInQ2NjMyIiwgMS4wXSwgWyJ0NjYzMyIsIDEuMF0sIFsidDY2MzQiLCAxLjBdLCBbInQ2NjM1IiwgMS4wXSwgWyJ0NjYzNiIsIDEuMF0sIFsidDY2MzciLCAxLjBdLCBbInQ2NjM4IiwgMS4wXSwgWyJ0NjYzOSIsIDEuMF0sIFsidDY2NDAiLCAxLjBdLCBbInQ2NjQxIiwgMS4wXSwgWyJ0NjY0MiIsIDEuMF0sIFsidDY2NDMiLCAxLjBdLCBbInQ2NjQ0IiwgMS4wXSwgWyJ0NjY0NSIsIDEuMF0sIFsidDY2NDYiLCAxLjBdLCBbInQ2NjQ3IiwgMS4wXSwgWyJ0NjY0OCIsIDEuMF0sIFsidDY2NDkiLCAxLjBdLCBbInQ2NjUwIiwgMS4wXSwgWyJ0NjY1MSIsIDEuMF0sIFsidDY2NTIiLCAxLjBdLCBbInQ2NjUzIiwgMS4wXSwgWyJ0NjY1NCIsIDEuMF0sIFsidDY2NTUiLCAxLjBdLCBbInQ2NjU2IiwgMS4wXSwgWyJ0NjY1NyIsIDEuMF0sIFsidDY2NTgiLCAxLjBdLCBbInQ2NjU5IiwgMS4wXSwgWyJ0NjY2MCIsIDEuMF0sIFsidDY2NjEiLCAxLjBdLCBbInQ2NjYyIiwgMS4wXSwgWyJ0NjY2MyIsIDEuMF0sIFsidDY2NjQiLCAxLjBdLCBbInQ2NjY1IiwgMS4wXSwgWyJ0NjY2NiIsIDEuMF0sIFsidDY2NjciLCAxLjBdLCBbInQ2NjY4IiwgMS4wXSwgWyJ0NjY2OSIsIDEuMF0sIFsidDY2NzAiLCAxLjBdLCBbInQ2NjcxIiwgMS4wXSwgWyJ0NjY3MiIsIDEuMF0sIFsidDY2NzMiLCAxLjBdLCBbInQ2Njc0IiwgMS4wXSwgWyJ0NjY3NSIsIDEuMF0sIFsidDY2NzYiLCAxLjBdLCBbInQ2Njc3IiwgMS4wXSwgWyJ0NjY3OCIsIDEuMF0sIFsidDY2NzkiLCAxLjBdLCBbInQ2NjgwIiwgMS4wXSwgWyJ0NjY4MSIsIDEuMF0sIFsidDY2ODIiLCAxLjBdLCBbInQ2NjgzIiwgMS4wXSwgWyJ0NjY4NCIsIDEuMF0sIFsidDY2ODUiLCAxLjBdLCBbInQ2Njg2IiwgMS4wXSwgWyJ0NjY4NyIsIDEuMF0sIFsidDY2ODgiLCAxLjBdLCBbInQ2Njg5IiwgMS4wXSwgWyJ0NjY5MCIsIDEuMF0sIFsidDY2OTEiLCAxLjBdLCBbInQ2NjkyIiwgMS4wXSwgWyJ0NjY5MyIsIDEuMF0sIFsidDY2OTQiLCAxLjBdLCBbInQ2Njk1IiwgMS4wXSwgWyJ0NjY5NiIsIDEuMF0sIFsidDY2OTciLCAxLjBdLCBbInQ2Njk4IiwgMS4wXSwgWyJ0NjY5OSIsIDEuMF0sIFsidDY3MDAiLCAxLjBdLCBbInQ2NzAxIiwgMS4wXSwgWyJ0NjcwMiIsIDEuMF0sIFsidDY3MDMiLCAxLjBdLCBbInQ2NzA0IiwgMS4wXSwgWyJ0NjcwNSIsIDEuMF0sIFsidDY3MDYiLCAxLjBdLCBbInQ2NzA3IiwgMS4wXSwgWyJ0NjcwOCIsIDEuMF0sIFsidDY3MDkiLCAxLjBdLCBbInQ2NzEwIiwgMS4wXSwgWyJ0NjcxMSIsIDEuMF0sIFsidDY3MTIiLCAxLjBdLCBbInQ2NzEzIiwgMS4wXSwgWyJ0NjcxNCIsIDEuMF0sIFsidDY3MTUiLCAxLjBdLCBbInQ2NzE2IiwgMS4wXSwgWyJ0NjcxNyIsIDEuMF0sIFsidDY3MTgiLCAxLjBdLCBbInQ2NzE5IiwgMS4wXSwgWyJ0NjcyMCIsIDEuMF0sIFsidDY3MjEiLCAxLjBdLCBbInQ2NzIyIiwgMS4wXSwgWyJ0NjcyMyIsIDEuMF0sIFsidDY3MjQiLCAxLjBdLCBbInQ2NzI1IiwgMS4wXSwgWyJ0NjcyNiIsIDEuMF0sIFsidDY3MjciLCAxLjBdLCBbInQ2NzI4IiwgMS4wXSwgWyJ0NjcyOSIsIDEuMF0sIFsidDY3MzAiLCAxLjBdLCBbInQ2NzMxIiwgMS4wXSwgWyJ0NjczMiIsIDEuMF0sIFsidDY3MzMiLCAxLjBdLCBbInQ2NzM0IiwgMS4wXSwgWyJ0NjczNSIsIDEuMF0sIFsidDY3MzYiLCAxLjBdLCBbInQ2NzM3IiwgMS4wXSwgWyJ0NjczOCIsIDEuMF0sIFsidDY3MzkiLCAxLjBdLCBbInQ2NzQwIiwgMS4wXSwgWyJ0Njc0MSIsIDEuMF0sIFsidDY3NDIiLCAxLjBdLCBbInQ2NzQzIiwgMS4wXSwgWyJ0Njc0NCIsIDEuMF0sIFsidDY3NDUiLCAxLjBdLCBbInQ2NzQ2IiwgMS4wXSwgWyJ0Njc0NyIsIDEuMF0sIFsidDY3NDgiLCAxLjBdLCBbInQ2NzQ5IiwgMS4wXSwgWyJ0Njc1MCIsIDEuMF0sIFsidDY3NTEiLCAxLjBdLCBbInQ2NzUyIiwgMS4wXSwgWyJ0Njc1MyIsIDEuMF0sIFsidDY3NTQiLCAxLjBdLCBbInQ2NzU1IiwgMS4wXSwgWyJ0Njc1NiIsIDEuMF0sIFsidDY3NTciLCAxLjBdLCBbInQ2NzU4IiwgMS4wXSwgWyJ0Njc1OSIsIDEuMF0sIFsidDY3NjAiLCAxLjBdLCBbInQ2NzYxIiwgMS4wXSwgWyJ0Njc2MiIsIDEuMF0sIFsidDY3NjMiLCAxLjBdLCBbInQ2NzY0IiwgMS4wXSwgWyJ0Njc2NSIsIDEuMF0sIFsidDY3NjYiLCAxLjBdLCBbInQ2NzY3IiwgMS4wXSwgWyJ0Njc2OCIsIDEuMF0sIFsidDY3NjkiLCAxLjBdLCBbInQ2NzcwIiwgMS4wXSwgWyJ0Njc3MSIsIDEuMF0sIFsidDY3NzIiLCAxLjBdLCBbInQ2NzczIiwgMS4wXSwgWyJ0Njc3NCIsIDEuMF0sIFsidDY3NzUiLCAxLjBdLCBbInQ2Nzc2IiwgMS4wXSwgWyJ0Njc3NyIsIDEuMF0sIFsidDY3NzgiLCAxLjBdLCBbInQ2Nzc5IiwgMS4wXSwgWyJ0Njc4MCIsIDEuMF0sIFsidDY3ODEiLCAxLjBdLCBbInQ2NzgyIiwgMS4wXSwgWyJ0Njc4MyIsIDEuMF0sIFsidDY3ODQiLCAxLjBdLCBbInQ2Nzg1IiwgMS4wXSwgWyJ0Njc4NiIsIDEuMF0sIFsidDY3ODciLCAxLjBdLCBbInQ2Nzg4IiwgMS4wXSwgWyJ0Njc4OSIsIDEuMF0sIFsidDY3OTAiLCAxLjBdLCBbInQ2NzkxIiwgMS4wXSwgWyJ0Njc5MiIsIDEuMF0sIFsidDY3OTMiLCAxLjBdLCBbInQ2Nzk0IiwgMS4wXSwgWyJ0Njc5NSIsIDEuMF0sIFsidDY3OTYiLCAxLjBdLCBbInQ2Nzk3IiwgMS4wXSwgWyJ0Njc5OCIsIDEuMF0sIFsidDY3OTkiLCAxLjBdLCBbInQ2ODAwIiwgMS4wXSwgWyJ0NjgwMSIsIDEuMF0sIFsidDY4MDIiLCAxLjBdLCBbInQ2ODAzIiwgMS4wXSwgWyJ0NjgwNCIsIDEuMF0sIFsidDY4MDUiLCAxLjBdLCBbInQ2ODA2IiwgMS4wXSwgWyJ0NjgwNyIsIDEuMF0sIFsidDY4MDgiLCAxLjBdLCBbInQ2ODA5IiwgMS4wXSwgWyJ0NjgxMCIsIDEuMF0sIFsidDY4MTEiLCAxLjBdLCBbInQ2ODEyIiwgMS4wXSwgWyJ0NjgxMyIsIDEuMF0sIFsidDY4MTQiLCAxLjBdLCBbInQ2ODE1IiwgMS4wXSwgWyJ0NjgxNiIsIDEuMF0sIFsidDY4MTciLCAxLjBdLCBbInQ2ODE4IiwgMS4wXSwgWyJ0NjgxOSIsIDEuMF0sIFsidDY4MjAiLCAxLjBdLCBbInQ2ODIxIiwgMS4wXSwgWyJ0NjgyMiIsIDEuMF0sIFsidDY4MjMiLCAxLjBdLCBbInQ2ODI0IiwgMS4wXSwgWyJ0NjgyNSIsIDEuMF0sIFsidDY4MjYiLCAxLjBdLCBbInQ2ODI3IiwgMS4wXSwgWyJ0NjgyOCIsIDEuMF0sIFsidDY4MjkiLCAxLjBdLCBbInQ2ODMwIiwgMS4wXSwgWyJ0NjgzMSIsIDEuMF0sIFsidDY4MzIiLCAxLjBdLCBbInQ2ODMzIiwgMS4wXSwgWyJ0NjgzNCIsIDEuMF0sIFsidDY4MzUiLCAxLjBdLCBbInQ2ODM2IiwgMS4wXSwgWyJ0NjgzNyIsIDEuMF0sIFsidDY4MzgiLCAxLjBdLCBbInQ2ODM5IiwgMS4wXSwgWyJ0Njg0MCIsIDEuMF0sIFsidDY4NDEiLCAxLjBdLCBbInQ2ODQyIiwgMS4wXSwgWyJ0Njg0MyIsIDEuMF0sIFsidDY4NDQiLCAxLjBdLCBbInQ2ODQ1IiwgMS4wXSwgWyJ0Njg0NiIsIDEuMF0sIFsidDY4NDciLCAxLjBdLCBbInQ2ODQ4IiwgMS4wXSwgWyJ0Njg0OSIsIDEuMF0sIFsidDY4NTAiLCAxLjBdLCBbInQ2ODUxIiwgMS4wXSwgWyJ0Njg1MiIsIDEuMF0sIFsidDY4NTMiLCAxLjBdLCBbInQ2ODU0IiwgMS4wXSwgWyJ0Njg1NSIsIDEuMF0sIFsidDY4NTYiLCAxLjBdLCBbInQ2ODU3IiwgMS4wXSwgWyJ0Njg1OCIsIDEuMF0sIFsidDY4NTkiLCAxLjBdLCBbInQ2ODYwIiwgMS4wXSwgWyJ0Njg2MSIsIDEuMF0sIFsidDY4NjIiLCAxLjBdLCBbInQ2ODYzIiwgMS4wXSwgWyJ0Njg2NCIsIDEuMF0sIFsidDY4NjUiLCAxLjBdLCBbInQ2ODY2IiwgMS4wXSwgWyJ0Njg2NyIsIDEuMF0sIFsidDY4NjgiLCAxLjBdLCBbInQ2ODY5IiwgMS4wXSwgWyJ0Njg3MCIsIDEuMF0sIFsidDY4NzEiLCAxLjBdLCBbInQ2ODcyIiwgMS4wXSwgWyJ0Njg3MyIsIDEuMF0sIFsidDY4NzQiLCAxLjBdLCBbInQ2ODc1IiwgMS4wXSwgWyJ0Njg3NiIsIDEuMF0sIFsidDY4NzciLCAxLjBdLCBbInQ2ODc4IiwgMS4wXSwgWyJ0Njg3OSIsIDEuMF0sIFsidDY4ODAiLCAxLjBdLCBbInQ2ODgxIiwgMS4wXSwgWyJ0Njg4MiIsIDEuMF0sIFsidDY4ODMiLCAxLjBdLCBbInQ2ODg0IiwgMS4wXSwgWyJ0Njg4NSIsIDEuMF0sIFsidDY4ODYiLCAxLjBdLCBbInQ2ODg3IiwgMS4wXSwgWyJ0Njg4OCIsIDEuMF0sIFsidDY4ODkiLCAxLjBdLCBbInQ2ODkwIiwgMS4wXSwgWyJ0Njg5MSIsIDEuMF0sIFsidDY4OTIiLCAxLjBdLCBbInQ2ODkzIiwgMS4wXSwgWyJ0Njg5NCIsIDEuMF0sIFsidDY4OTUiLCAxLjBdLCBbInQ2ODk2IiwgMS4wXSwgWyJ0Njg5NyIsIDEuMF0sIFsidDY4OTgiLCAxLjBdLCBbInQ2ODk5IiwgMS4wXSwgWyJ0NjkwMCIsIDEuMF0sIFsidDY5MDEiLCAxLjBdLCBbInQ2OTAyIiwgMS4wXSwgWyJ0NjkwMyIsIDEuMF0sIFsidDY5MDQiLCAxLjBdLCBbInQ2OTA1IiwgMS4wXSwgWyJ0NjkwNiIsIDEuMF0sIFsidDY5MDciLCAxLjBdLCBbInQ2OTA4IiwgMS4wXSwgWyJ0NjkwOSIsIDEuMF0sIFsidDY5MTAiLCAxLjBdLCBbInQ2OTExIiwgMS4wXSwgWyJ0NjkxMiIsIDEuMF0sIFsidDY5MTMiLCAxLjBdLCBbInQ2OTE0IiwgMS4wXSwgWyJ0NjkxNSIsIDEuMF0sIFsidDY5MTYiLCAxLjBdLCBbInQ2OTE3IiwgMS4wXSwgWyJ0NjkxOCIsIDEuMF0sIFsidDY5MTkiLCAxLjBdLCBbInQ2OTIwIiwgMS4wXSwgWyJ0NjkyMSIsIDEuMF0sIFsidDY5MjIiLCAxLjBdLCBbInQ2OTIzIiwgMS4wXSwgWyJ0NjkyNCIsIDEuMF0sIFsidDY5MjUiLCAxLjBdLCBbInQ2OTI2IiwgMS4wXSwgWyJ0NjkyNyIsIDEuMF0sIFsidDY5MjgiLCAxLjBdLCBbInQ2OTI5IiwgMS4wXSwgWyJ0NjkzMCIsIDEuMF0sIFsidDY5MzEiLCAxLjBdLCBbInQ2OTMyIiwgMS4wXSwgWyJ0NjkzMyIsIDEuMF0sIFsidDY5MzQiLCAxLjBdLCBbInQ2OTM1IiwgMS4wXSwgWyJ0NjkzNiIsIDEuMF0sIFsidDY5MzciLCAxLjBdLCBbInQ2OTM4IiwgMS4wXSwgWyJ0NjkzOSIsIDEuMF0sIFsidDY5NDAiLCAxLjBdLCBbInQ2OTQxIiwgMS4wXSwgWyJ0Njk0MiIsIDEuMF0sIFsidDY5NDMiLCAxLjBdLCBbInQ2OTQ0IiwgMS4wXSwgWyJ0Njk0NSIsIDEuMF0sIFsidDY5NDYiLCAxLjBdLCBbInQ2OTQ3IiwgMS4wXSwgWyJ0Njk0OCIsIDEuMF0sIFsidDY5NDkiLCAxLjBdLCBbInQ2OTUwIiwgMS4wXSwgWyJ0Njk1MSIsIDEuMF0sIFsidDY5NTIiLCAxLjBdLCBbInQ2OTUzIiwgMS4wXSwgWyJ0Njk1NCIsIDEuMF0sIFsidDY5NTUiLCAxLjBdLCBbInQ2OTU2IiwgMS4wXSwgWyJ0Njk1NyIsIDEuMF0sIFsidDY5NTgiLCAxLjBdLCBbInQ2OTU5IiwgMS4wXSwgWyJ0Njk2MCIsIDEuMF0sIFsidDY5NjEiLCAxLjBdLCBbInQ2OTYyIiwgMS4wXSwgWyJ0Njk2MyIsIDEuMF0sIFsidDY5NjQiLCAxLjBdLCBbInQ2OTY1IiwgMS4wXSwgWyJ0Njk2NiIsIDEuMF0sIFsidDY5NjciLCAxLjBdLCBbInQ2OTY4IiwgMS4wXSwgWyJ0Njk2OSIsIDEuMF0sIFsidDY5NzAiLCAxLjBdLCBbInQ2OTcxIiwgMS4wXSwgWyJ0Njk3MiIsIDEuMF0sIFsidDY5NzMiLCAxLjBdLCBbInQ2OTc0IiwgMS4wXSwgWyJ0Njk3NSIsIDEuMF0sIFsidDY5NzYiLCAxLjBdLCBbInQ2OTc3IiwgMS4wXSwgWyJ0Njk3OCIsIDEuMF0sIFsidDY5NzkiLCAxLjBdLCBbInQ2OTgwIiwgMS4wXSwgWyJ0Njk4MSIsIDEuMF0sIFsidDY5ODIiLCAxLjBdLCBbInQ2OTgzIiwgMS4wXSwgWyJ0Njk4NCIsIDEuMF0sIFsidDY5ODUiLCAxLjBdLCBbInQ2OTg2IiwgMS4wXSwgWyJ0Njk4NyIsIDEuMF0sIFsidDY5ODgiLCAxLjBdLCBbInQ2OTg5IiwgMS4wXSwgWyJ0Njk5MCIsIDEuMF0sIFsidDY5OTEiLCAxLjBdLCBbInQ2OTkyIiwgMS4wXSwgWyJ0Njk5MyIsIDEuMF0sIFsidDY5OTQiLCAxLjBdLCBbInQ2OTk1IiwgMS4wXSwgWyJ0Njk5NiIsIDEuMF0sIFsidDY5OTciLCAxLjBdLCBbInQ2OTk4IiwgMS4wXSwgWyJ0Njk5OSIsIDEuMF0sIFsidDcwMDAiLCAxLjBdLCBbInQ3MDAxIiwgMS4wXSwgWyJ0NzAwMiIsIDEuMF0sIFsidDcwMDMiLCAxLjBdLCBbInQ3MDA0IiwgMS4wXSwgWyJ0NzAwNSIsIDEuMF0sIFsidDcwMDYiLCAxLjBdLCBbInQ3MDA3IiwgMS4wXSwgWyJ0NzAwOCIsIDEuMF0sIFsidDcwMDkiLCAxLjBdLCBbInQ3MDEwIiwgMS4wXSwgWyJ0NzAxMSIsIDEuMF0sIFsidDcwMTIiLCAxLjBdLCBbInQ3MDEzIiwgMS4wXSwgWyJ0NzAxNCIsIDEuMF0sIFsidDcwMTUiLCAxLjBdLCBbInQ3MDE2IiwgMS4wXSwgWyJ0NzAxNyIsIDEuMF0sIFsidDcwMTgiLCAxLjBdLCBbInQ3MDE5IiwgMS4wXSwgWyJ0NzAyMCIsIDEuMF0sIFsidDcwMjEiLCAxLjBdLCBbInQ3MDIyIiwgMS4wXSwgWyJ0NzAyMyIsIDEuMF0sIFsidDcwMjQiLCAxLjBdLCBbInQ3MDI1IiwgMS4wXSwgWyJ0NzAyNiIsIDEuMF0sIFsidDcwMjciLCAxLjBdLCBbInQ3MDI4IiwgMS4wXSwgWyJ0NzAyOSIsIDEuMF0sIFsidDcwMzAiLCAxLjBdLCBbInQ3MDMxIiwgMS4wXSwgWyJ0NzAzMiIsIDEuMF0sIFsidDcwMzMiLCAxLjBdLCBbInQ3MDM0IiwgMS4wXSwgWyJ0NzAzNSIsIDEuMF0sIFsidDcwMzYiLCAxLjBdLCBbInQ3MDM3IiwgMS4wXSwgWyJ0NzAzOCIsIDEuMF0sIFsidDcwMzkiLCAxLjBdLCBbInQ3MDQwIiwgMS4wXSwgWyJ0NzA0MSIsIDEuMF0sIFsidDcwNDIiLCAxLjBdLCBbInQ3MDQzIiwgMS4wXSwgWyJ0NzA0NCIsIDEuMF0sIFsidDcwNDUiLCAxLjBdLCBbInQ3MDQ2IiwgMS4wXSwgWyJ0NzA0NyIsIDEuMF0sIFsidDcwNDgiLCAxLjBdLCBbInQ3MDQ5IiwgMS4wXSwgWyJ0NzA1MCIsIDEuMF0sIFsidDcwNTEiLCAxLjBdLCBbInQ3MDUyIiwgMS4wXSwgWyJ0NzA1MyIsIDEuMF0sIFsidDcwNTQiLCAxLjBdLCBbInQ3MDU1IiwgMS4wXSwgWyJ0NzA1NiIsIDEuMF0sIFsidDcwNTciLCAxLjBdLCBbInQ3MDU4IiwgMS4wXSwgWyJ0NzA1OSIsIDEuMF0sIFsidDcwNjAiLCAxLjBdLCBbInQ3MDYxIiwgMS4wXSwgWyJ0NzA2MiIsIDEuMF0sIFsidDcwNjMiLCAxLjBdLCBbInQ3MDY0IiwgMS4wXSwgWyJ0NzA2NSIsIDEuMF0sIFsidDcwNjYiLCAxLjBdLCBbInQ3MDY3IiwgMS4wXSwgWyJ0NzA2OCIsIDEuMF0sIFsidDcwNjkiLCAxLjBdLCBbInQ3MDcwIiwgMS4wXSwgWyJ0NzA3MSIsIDEuMF0sIFsidDcwNzIiLCAxLjBdLCBbInQ3MDczIiwgMS4wXSwgWyJ0NzA3NCIsIDEuMF0sIFsidDcwNzUiLCAxLjBdLCBbInQ3MDc2IiwgMS4wXSwgWyJ0NzA3NyIsIDEuMF0sIFsidDcwNzgiLCAxLjBdLCBbInQ3MDc5IiwgMS4wXSwgWyJ0NzA4MCIsIDEuMF0sIFsidDcwODEiLCAxLjBdLCBbInQ3MDgyIiwgMS4wXSwgWyJ0NzA4MyIsIDEuMF0sIFsidDcwODQiLCAxLjBdLCBbInQ3MDg1IiwgMS4wXSwgWyJ0NzA4NiIsIDEuMF0sIFsidDcwODciLCAxLjBdLCBbInQ3MDg4IiwgMS4wXSwgWyJ0NzA4OSIsIDEuMF0sIFsidDcwOTAiLCAxLjBdLCBbInQ3MDkxIiwgMS4wXSwgWyJ0NzA5MiIsIDEuMF0sIFsidDcwOTMiLCAxLjBdLCBbInQ3MDk0IiwgMS4wXSwgWyJ0NzA5NSIsIDEuMF0sIFsidDcwOTYiLCAxLjBdLCBbInQ3MDk3IiwgMS4wXSwgWyJ0NzA5OCIsIDEuMF0sIFsidDcwOTkiLCAxLjBdLCBbInQ3MTAwIiwgMS4wXSwgWyJ0NzEwMSIsIDEuMF0sIFsidDcxMDIiLCAxLjBdLCBbInQ3MTAzIiwgMS4wXSwgWyJ0NzEwNCIsIDEuMF0sIFsidDcxMDUiLCAxLjBdLCBbInQ3MTA2IiwgMS4wXSwgWyJ0NzEwNyIsIDEuMF0sIFsidDcxMDgiLCAxLjBdLCBbInQ3MTA5IiwgMS4wXSwgWyJ0NzExMCIsIDEuMF0sIFsidDcxMTEiLCAxLjBdLCBbInQ3MTEyIiwgMS4wXSwgWyJ0NzExMyIsIDEuMF0sIFsidDcxMTQiLCAxLjBdLCBbInQ3MTE1IiwgMS4wXSwgWyJ0NzExNiIsIDEuMF0sIFsidDcxMTciLCAxLjBdLCBbInQ3MTE4IiwgMS4wXSwgWyJ0NzExOSIsIDEuMF0sIFsidDcxMjAiLCAxLjBdLCBbInQ3MTIxIiwgMS4wXSwgWyJ0NzEyMiIsIDEuMF0sIFsidDcxMjMiLCAxLjBdLCBbInQ3MTI0IiwgMS4wXSwgWyJ0NzEyNSIsIDEuMF0sIFsidDcxMjYiLCAxLjBdLCBbInQ3MTI3IiwgMS4wXSwgWyJ0NzEyOCIsIDEuMF0sIFsidDcxMjkiLCAxLjBdLCBbInQ3MTMwIiwgMS4wXSwgWyJ0NzEzMSIsIDEuMF0sIFsidDcxMzIiLCAxLjBdLCBbInQ3MTMzIiwgMS4wXSwgWyJ0NzEzNCIsIDEuMF0sIFsidDcxMzUiLCAxLjBdLCBbInQ3MTM2IiwgMS4wXSwgWyJ0NzEzNyIsIDEuMF0sIFsidDcxMzgiLCAxLjBdLCBbInQ3MTM5IiwgMS4wXSwgWyJ0NzE0MCIsIDEuMF0sIFsidDcxNDEiLCAxLjBdLCBbInQ3MTQyIiwgMS4wXSwgWyJ0NzE0MyIsIDEuMF0sIFsidDcxNDQiLCAxLjBdLCBbInQ3MTQ1IiwgMS4wXSwgWyJ0NzE0NiIsIDEuMF0sIFsidDcxNDciLCAxLjBdLCBbInQ3MTQ4IiwgMS4wXSwgWyJ0NzE0OSIsIDEuMF0sIFsidDcxNTAiLCAxLjBdLCBbInQ3MTUxIiwgMS4wXSwgWyJ0NzE1MiIsIDEuMF0sIFsidDcxNTMiLCAxLjBdLCBbInQ3MTU0IiwgMS4wXSwgWyJ0NzE1NSIsIDEuMF0sIFsidDcxNTYiLCAxLjBdLCBbInQ3MTU3IiwgMS4wXSwgWyJ0NzE1OCIsIDEuMF0sIFsidDcxNTkiLCAxLjBdLCBbInQ3MTYwIiwgMS4wXSwgWyJ0NzE2MSIsIDEuMF0sIFsidDcxNjIiLCAxLjBdLCBbInQ3MTYzIiwgMS4wXSwgWyJ0NzE2NCIsIDEuMF0sIFsidDcxNjUiLCAxLjBdLCBbInQ3MTY2IiwgMS4wXSwgWyJ0NzE2NyIsIDEuMF0sIFsidDcxNjgiLCAxLjBdLCBbInQ3MTY5IiwgMS4wXSwgWyJ0NzE3MCIsIDEuMF0sIFsidDcxNzEiLCAxLjBdLCBbInQ3MTcyIiwgMS4wXSwgWyJ0NzE3MyIsIDEuMF0sIFsidDcxNzQiLCAxLjBdLCBbInQ3MTc1IiwgMS4wXSwgWyJ0NzE3NiIsIDEuMF0sIFsidDcxNzciLCAxLjBdLCBbInQ3MTc4IiwgMS4wXSwgWyJ0NzE3OSIsIDEuMF0sIFsidDcxODAiLCAxLjBdLCBbInQ3MTgxIiwgMS4wXSwgWyJ0NzE4MiIsIDEuMF0sIFsidDcxODMiLCAxLjBdLCBbInQ3MTg0IiwgMS4wXSwgWyJ0NzE4NSIsIDEuMF0sIFsidDcxODYiLCAxLjBdLCBbInQ3MTg3IiwgMS4wXSwgWyJ0NzE4OCIsIDEuMF0sIFsidDcxODkiLCAxLjBdLCBbInQ3MTkwIiwgMS4wXSwgWyJ0NzE5MSIsIDEuMF0sIFsidDcxOTIiLCAxLjBdLCBbInQ3MTkzIiwgMS4wXSwgWyJ0NzE5NCIsIDEuMF0sIFsidDcxOTUiLCAxLjBdLCBbInQ3MTk2IiwgMS4wXSwgWyJ0NzE5NyIsIDEuMF0sIFsidDcxOTgiLCAxLjBdLCBbInQ3MTk5IiwgMS4wXSwgWyJ0NzIwMCIsIDEuMF0sIFsidDcyMDEiLCAxLjBdLCBbInQ3MjAyIiwgMS4wXSwgWyJ0NzIwMyIsIDEuMF0sIFsidDcyMDQiLCAxLjBdLCBbInQ3MjA1IiwgMS4wXSwgWyJ0NzIwNiIsIDEuMF0sIFsidDcyMDciLCAxLjBdLCBbInQ3MjA4IiwgMS4wXSwgWyJ0NzIwOSIsIDEuMF0sIFsidDcyMTAiLCAxLjBdLCBbInQ3MjExIiwgMS4wXSwgWyJ0NzIxMiIsIDEuMF0sIFsidDcyMTMiLCAxLjBdLCBbInQ3MjE0IiwgMS4wXSwgWyJ0NzIxNSIsIDEuMF0sIFsidDcyMTYiLCAxLjBdLCBbInQ3MjE3IiwgMS4wXSwgWyJ0NzIxOCIsIDEuMF0sIFsidDcyMTkiLCAxLjBdLCBbInQ3MjIwIiwgMS4wXSwgWyJ0NzIyMSIsIDEuMF0sIFsidDcyMjIiLCAxLjBdLCBbInQ3MjIzIiwgMS4wXSwgWyJ0NzIyNCIsIDEuMF0sIFsidDcyMjUiLCAxLjBdLCBbInQ3MjI2IiwgMS4wXSwgWyJ0NzIyNyIsIDEuMF0sIFsidDcyMjgiLCAxLjBdLCBbInQ3MjI5IiwgMS4wXSwgWyJ0NzIzMCIsIDEuMF0sIFsidDcyMzEiLCAxLjBdLCBbInQ3MjMyIiwgMS4wXSwgWyJ0NzIzMyIsIDEuMF0sIFsidDcyMzQiLCAxLjBdLCBbInQ3MjM1IiwgMS4wXSwgWyJ0NzIzNiIsIDEuMF0sIFsidDcyMzciLCAxLjBdLCBbInQ3MjM4IiwgMS4wXSwgWyJ0NzIzOSIsIDEuMF0sIFsidDcyNDAiLCAxLjBdLCBbInQ3MjQxIiwgMS4wXSwgWyJ0NzI0MiIsIDEuMF0sIFsidDcyNDMiLCAxLjBdLCBbInQ3MjQ0IiwgMS4wXSwgWyJ0NzI0NSIsIDEuMF0sIFsidDcyNDYiLCAxLjBdLCBbInQ3MjQ3IiwgMS4wXSwgWyJ0NzI0OCIsIDEuMF0sIFsidDcyNDkiLCAxLjBdLCBbInQ3MjUwIiwgMS4wXSwgWyJ0NzI1MSIsIDEuMF0sIFsidDcyNTIiLCAxLjBdLCBbInQ3MjUzIiwgMS4wXSwgWyJ0NzI1NCIsIDEuMF0sIFsidDcyNTUiLCAxLjBdLCBbInQ3MjU2IiwgMS4wXSwgWyJ0NzI1NyIsIDEuMF0sIFsidDcyNTgiLCAxLjBdLCBbInQ3MjU5IiwgMS4wXSwgWyJ0NzI2MCIsIDEuMF0sIFsidDcyNjEiLCAxLjBdLCBbInQ3MjYyIiwgMS4wXSwgWyJ0NzI2MyIsIDEuMF0sIFsidDcyNjQiLCAxLjBdLCBbInQ3MjY1IiwgMS4wXSwgWyJ0NzI2NiIsIDEuMF0sIFsidDcyNjciLCAxLjBdLCBbInQ3MjY4IiwgMS4wXSwgWyJ0NzI2OSIsIDEuMF0sIFsidDcyNzAiLCAxLjBdLCBbInQ3MjcxIiwgMS4wXSwgWyJ0NzI3MiIsIDEuMF0sIFsidDcyNzMiLCAxLjBdLCBbInQ3Mjc0IiwgMS4wXSwgWyJ0NzI3NSIsIDEuMF0sIFsidDcyNzYiLCAxLjBdLCBbInQ3Mjc3IiwgMS4wXSwgWyJ0NzI3OCIsIDEuMF0sIFsidDcyNzkiLCAxLjBdLCBbInQ3MjgwIiwgMS4wXSwgWyJ0NzI4MSIsIDEuMF0sIFsidDcyODIiLCAxLjBdLCBbInQ3MjgzIiwgMS4wXSwgWyJ0NzI4NCIsIDEuMF0sIFsidDcyODUiLCAxLjBdLCBbInQ3Mjg2IiwgMS4wXSwgWyJ0NzI4NyIsIDEuMF0sIFsidDcyODgiLCAxLjBdLCBbInQ3Mjg5IiwgMS4wXSwgWyJ0NzI5MCIsIDEuMF0sIFsidDcyOTEiLCAxLjBdLCBbInQ3MjkyIiwgMS4wXSwgWyJ0NzI5MyIsIDEuMF0sIFsidDcyOTQiLCAxLjBdLCBbInQ3Mjk1IiwgMS4wXSwgWyJ0NzI5NiIsIDEuMF0sIFsidDcyOTciLCAxLjBdLCBbInQ3Mjk4IiwgMS4wXSwgWyJ0NzI5OSIsIDEuMF0sIFsidDczMDAiLCAxLjBdLCBbInQ3MzAxIiwgMS4wXSwgWyJ0NzMwMiIsIDEuMF0sIFsidDczMDMiLCAxLjBdLCBbInQ3MzA0IiwgMS4wXSwgWyJ0NzMwNSIsIDEuMF0sIFsidDczMDYiLCAxLjBdLCBbInQ3MzA3IiwgMS4wXSwgWyJ0NzMwOCIsIDEuMF0sIFsidDczMDkiLCAxLjBdLCBbInQ3MzEwIiwgMS4wXSwgWyJ0NzMxMSIsIDEuMF0sIFsidDczMTIiLCAxLjBdLCBbInQ3MzEzIiwgMS4wXSwgWyJ0NzMxNCIsIDEuMF0sIFsidDczMTUiLCAxLjBdLCBbInQ3MzE2IiwgMS4wXSwgWyJ0NzMxNyIsIDEuMF0sIFsidDczMTgiLCAxLjBdLCBbInQ3MzE5IiwgMS4wXSwgWyJ0NzMyMCIsIDEuMF0sIFsidDczMjEiLCAxLjBdLCBbInQ3MzIyIiwgMS4wXSwgWyJ0NzMyMyIsIDEuMF0sIFsidDczMjQiLCAxLjBdLCBbInQ3MzI1IiwgMS4wXSwgWyJ0NzMyNiIsIDEuMF0sIFsidDczMjciLCAxLjBdLCBbInQ3MzI4IiwgMS4wXSwgWyJ0NzMyOSIsIDEuMF0sIFsidDczMzAiLCAxLjBdLCBbInQ3MzMxIiwgMS4wXSwgWyJ0NzMzMiIsIDEuMF0sIFsidDczMzMiLCAxLjBdLCBbInQ3MzM0IiwgMS4wXSwgWyJ0NzMzNSIsIDEuMF0sIFsidDczMzYiLCAxLjBdLCBbInQ3MzM3IiwgMS4wXSwgWyJ0NzMzOCIsIDEuMF0sIFsidDczMzkiLCAxLjBdLCBbInQ3MzQwIiwgMS4wXSwgWyJ0NzM0MSIsIDEuMF0sIFsidDczNDIiLCAxLjBdLCBbInQ3MzQzIiwgMS4wXSwgWyJ0NzM0NCIsIDEuMF0sIFsidDczNDUiLCAxLjBdLCBbInQ3MzQ2IiwgMS4wXSwgWyJ0NzM0NyIsIDEuMF0sIFsidDczNDgiLCAxLjBdLCBbInQ3MzQ5IiwgMS4wXSwgWyJ0NzM1MCIsIDEuMF0sIFsidDczNTEiLCAxLjBdLCBbInQ3MzUyIiwgMS4wXSwgWyJ0NzM1MyIsIDEuMF0sIFsidDczNTQiLCAxLjBdLCBbInQ3MzU1IiwgMS4wXSwgWyJ0NzM1NiIsIDEuMF0sIFsidDczNTciLCAxLjBdLCBbInQ3MzU4IiwgMS4wXSwgWyJ0NzM1OSIsIDEuMF0sIFsidDczNjAiLCAxLjBdLCBbInQ3MzYxIiwgMS4wXSwgWyJ0NzM2MiIsIDEuMF0sIFsidDczNjMiLCAxLjBdLCBbInQ3MzY0IiwgMS4wXSwgWyJ0NzM2NSIsIDEuMF0sIFsidDczNjYiLCAxLjBdLCBbInQ3MzY3IiwgMS4wXSwgWyJ0NzM2OCIsIDEuMF0sIFsidDczNjkiLCAxLjBdLCBbInQ3MzcwIiwgMS4wXSwgWyJ0NzM3MSIsIDEuMF0sIFsidDczNzIiLCAxLjBdLCBbInQ3MzczIiwgMS4wXSwgWyJ0NzM3NCIsIDEuMF0sIFsidDczNzUiLCAxLjBdLCBbInQ3Mzc2IiwgMS4wXSwgWyJ0NzM3NyIsIDEuMF0sIFsidDczNzgiLCAxLjBdLCBbInQ3Mzc5IiwgMS4wXSwgWyJ0NzM4MCIsIDEuMF0sIFsidDczODEiLCAxLjBdLCBbInQ3MzgyIiwgMS4wXSwgWyJ0NzM4MyIsIDEuMF0sIFsidDczODQiLCAxLjBdLCBbInQ3Mzg1IiwgMS4wXSwgWyJ0NzM4NiIsIDEuMF0sIFsidDczODciLCAxLjBdLCBbInQ3Mzg4IiwgMS4wXSwgWyJ0NzM4OSIsIDEuMF0sIFsidDczOTAiLCAxLjBdLCBbInQ3MzkxIiwgMS4wXSwgWyJ0NzM5MiIsIDEuMF0sIFsidDczOTMiLCAxLjBdLCBbInQ3Mzk0IiwgMS4wXSwgWyJ0NzM5NSIsIDEuMF0sIFsidDczOTYiLCAxLjBdLCBbInQ3Mzk3IiwgMS4wXSwgWyJ0NzM5OCIsIDEuMF0sIFsidDczOTkiLCAxLjBdLCBbInQ3NDAwIiwgMS4wXSwgWyJ0NzQwMSIsIDEuMF0sIFsidDc0MDIiLCAxLjBdLCBbInQ3NDAzIiwgMS4wXSwgWyJ0NzQwNCIsIDEuMF0sIFsidDc0MDUiLCAxLjBdLCBbInQ3NDA2IiwgMS4wXSwgWyJ0NzQwNyIsIDEuMF0sIFsidDc0MDgiLCAxLjBdLCBbInQ3NDA5IiwgMS4wXSwgWyJ0NzQxMCIsIDEuMF0sIFsidDc0MTEiLCAxLjBdLCBbInQ3NDEyIiwgMS4wXSwgWyJ0NzQxMyIsIDEuMF0sIFsidDc0MTQiLCAxLjBdLCBbInQ3NDE1IiwgMS4wXSwgWyJ0NzQxNiIsIDEuMF0sIFsidDc0MTciLCAxLjBdLCBbInQ3NDE4IiwgMS4wXSwgWyJ0NzQxOSIsIDEuMF0sIFsidDc0MjAiLCAxLjBdLCBbInQ3NDIxIiwgMS4wXSwgWyJ0NzQyMiIsIDEuMF0sIFsidDc0MjMiLCAxLjBdLCBbInQ3NDI0IiwgMS4wXSwgWyJ0NzQyNSIsIDEuMF0sIFsidDc0MjYiLCAxLjBdLCBbInQ3NDI3IiwgMS4wXSwgWyJ0NzQyOCIsIDEuMF0sIFsidDc0MjkiLCAxLjBdLCBbInQ3NDMwIiwgMS4wXSwgWyJ0NzQzMSIsIDEuMF0sIFsidDc0MzIiLCAxLjBdLCBbInQ3NDMzIiwgMS4wXSwgWyJ0NzQzNCIsIDEuMF0sIFsidDc0MzUiLCAxLjBdLCBbInQ3NDM2IiwgMS4wXSwgWyJ0NzQzNyIsIDEuMF0sIFsidDc0MzgiLCAxLjBdLCBbInQ3NDM5IiwgMS4wXSwgWyJ0NzQ0MCIsIDEuMF0sIFsidDc0NDEiLCAxLjBdLCBbInQ3NDQyIiwgMS4wXSwgWyJ0NzQ0MyIsIDEuMF0sIFsidDc0NDQiLCAxLjBdLCBbInQ3NDQ1IiwgMS4wXSwgWyJ0NzQ0NiIsIDEuMF0sIFsidDc0NDciLCAxLjBdLCBbInQ3NDQ4IiwgMS4wXSwgWyJ0NzQ0OSIsIDEuMF0sIFsidDc0NTAiLCAxLjBdLCBbInQ3NDUxIiwgMS4wXSwgWyJ0NzQ1MiIsIDEuMF0sIFsidDc0NTMiLCAxLjBdLCBbInQ3NDU0IiwgMS4wXSwgWyJ0NzQ1NSIsIDEuMF0sIFsidDc0NTYiLCAxLjBdLCBbInQ3NDU3IiwgMS4wXSwgWyJ0NzQ1OCIsIDEuMF0sIFsidDc0NTkiLCAxLjBdLCBbInQ3NDYwIiwgMS4wXSwgWyJ0NzQ2MSIsIDEuMF0sIFsidDc0NjIiLCAxLjBdLCBbInQ3NDYzIiwgMS4wXSwgWyJ0NzQ2NCIsIDEuMF0sIFsidDc0NjUiLCAxLjBdLCBbInQ3NDY2IiwgMS4wXSwgWyJ0NzQ2NyIsIDEuMF0sIFsidDc0NjgiLCAxLjBdLCBbInQ3NDY5IiwgMS4wXSwgWyJ0NzQ3MCIsIDEuMF0sIFsidDc0NzEiLCAxLjBdLCBbInQ3NDcyIiwgMS4wXSwgWyJ0NzQ3MyIsIDEuMF0sIFsidDc0NzQiLCAxLjBdLCBbInQ3NDc1IiwgMS4wXSwgWyJ0NzQ3NiIsIDEuMF0sIFsidDc0NzciLCAxLjBdLCBbInQ3NDc4IiwgMS4wXSwgWyJ0NzQ3OSIsIDEuMF0sIFsidDc0ODAiLCAxLjBdLCBbInQ3NDgxIiwgMS4wXSwgWyJ0NzQ4MiIsIDEuMF0sIFsidDc0ODMiLCAxLjBdLCBbInQ3NDg0IiwgMS4wXSwgWyJ0NzQ4NSIsIDEuMF0sIFsidDc0ODYiLCAxLjBdLCBbInQ3NDg3IiwgMS4wXSwgWyJ0NzQ4OCIsIDEuMF0sIFsidDc0ODkiLCAxLjBdLCBbInQ3NDkwIiwgMS4wXSwgWyJ0NzQ5MSIsIDEuMF0sIFsidDc0OTIiLCAxLjBdLCBbInQ3NDkzIiwgMS4wXSwgWyJ0NzQ5NCIsIDEuMF0sIFsidDc0OTUiLCAxLjBdLCBbInQ3NDk2IiwgMS4wXSwgWyJ0NzQ5NyIsIDEuMF0sIFsidDc0OTgiLCAxLjBdLCBbInQ3NDk5IiwgMS4wXSwgWyJ0NzUwMCIsIDEuMF0sIFsidDc1MDEiLCAxLjBdLCBbInQ3NTAyIiwgMS4wXSwgWyJ0NzUwMyIsIDEuMF0sIFsidDc1MDQiLCAxLjBdLCBbInQ3NTA1IiwgMS4wXSwgWyJ0NzUwNiIsIDEuMF0sIFsidDc1MDciLCAxLjBdLCBbInQ3NTA4IiwgMS4wXSwgWyJ0NzUwOSIsIDEuMF0sIFsidDc1MTAiLCAxLjBdLCBbInQ3NTExIiwgMS4wXSwgWyJ0NzUxMiIsIDEuMF0sIFsidDc1MTMiLCAxLjBdLCBbInQ3NTE0IiwgMS4wXSwgWyJ0NzUxNSIsIDEuMF0sIFsidDc1MTYiLCAxLjBdLCBbInQ3NTE3IiwgMS4wXSwgWyJ0NzUxOCIsIDEuMF0sIFsidDc1MTkiLCAxLjBdLCBbInQ3NTIwIiwgMS4wXSwgWyJ0NzUyMSIsIDEuMF0sIFsidDc1MjIiLCAxLjBdLCBbInQ3NTIzIiwgMS4wXSwgWyJ0NzUyNCIsIDEuMF0sIFsidDc1MjUiLCAxLjBdLCBbInQ3NTI2IiwgMS4wXSwgWyJ0NzUyNyIsIDEuMF0sIFsidDc1MjgiLCAxLjBdLCBbInQ3NTI5IiwgMS4wXSwgWyJ0NzUzMCIsIDEuMF0sIFsidDc1MzEiLCAxLjBdLCBbInQ3NTMyIiwgMS4wXSwgWyJ0NzUzMyIsIDEuMF0sIFsidDc1MzQiLCAxLjBdLCBbInQ3NTM1IiwgMS4wXSwgWyJ0NzUzNiIsIDEuMF0sIFsidDc1MzciLCAxLjBdLCBbInQ3NTM4IiwgMS4wXSwgWyJ0NzUzOSIsIDEuMF0sIFsidDc1NDAiLCAxLjBdLCBbInQ3NTQxIiwgMS4wXSwgWyJ0NzU0MiIsIDEuMF0sIFsidDc1NDMiLCAxLjBdLCBbInQ3NTQ0IiwgMS4wXSwgWyJ0NzU0NSIsIDEuMF0sIFsidDc1NDYiLCAxLjBdLCBbInQ3NTQ3IiwgMS4wXSwgWyJ0NzU0OCIsIDEuMF0sIFsidDc1NDkiLCAxLjBdLCBbInQ3NTUwIiwgMS4wXSwgWyJ0NzU1MSIsIDEuMF0sIFsidDc1NTIiLCAxLjBdLCBbInQ3NTUzIiwgMS4wXSwgWyJ0NzU1NCIsIDEuMF0sIFsidDc1NTUiLCAxLjBdLCBbInQ3NTU2IiwgMS4wXSwgWyJ0NzU1NyIsIDEuMF0sIFsidDc1NTgiLCAxLjBdLCBbInQ3NTU5IiwgMS4wXSwgWyJ0NzU2MCIsIDEuMF0sIFsidDc1NjEiLCAxLjBdLCBbInQ3NTYyIiwgMS4wXSwgWyJ0NzU2MyIsIDEuMF0sIFsidDc1NjQiLCAxLjBdLCBbInQ3NTY1IiwgMS4wXSwgWyJ0NzU2NiIsIDEuMF0sIFsidDc1NjciLCAxLjBdLCBbInQ3NTY4IiwgMS4wXSwgWyJ0NzU2OSIsIDEuMF0sIFsidDc1NzAiLCAxLjBdLCBbInQ3NTcxIiwgMS4wXSwgWyJ0NzU3MiIsIDEuMF0sIFsidDc1NzMiLCAxLjBdLCBbInQ3NTc0IiwgMS4wXSwgWyJ0NzU3NSIsIDEuMF0sIFsidDc1NzYiLCAxLjBdLCBbInQ3NTc3IiwgMS4wXSwgWyJ0NzU3OCIsIDEuMF0sIFsidDc1NzkiLCAxLjBdLCBbInQ3NTgwIiwgMS4wXSwgWyJ0NzU4MSIsIDEuMF0sIFsidDc1ODIiLCAxLjBdLCBbInQ3NTgzIiwgMS4wXSwgWyJ0NzU4NCIsIDEuMF0sIFsidDc1ODUiLCAxLjBdLCBbInQ3NTg2IiwgMS4wXSwgWyJ0NzU4NyIsIDEuMF0sIFsidDc1ODgiLCAxLjBdLCBbInQ3NTg5IiwgMS4wXSwgWyJ0NzU5MCIsIDEuMF0sIFsidDc1OTEiLCAxLjBdLCBbInQ3NTkyIiwgMS4wXSwgWyJ0NzU5MyIsIDEuMF0sIFsidDc1OTQiLCAxLjBdLCBbInQ3NTk1IiwgMS4wXSwgWyJ0NzU5NiIsIDEuMF0sIFsidDc1OTciLCAxLjBdLCBbInQ3NTk4IiwgMS4wXSwgWyJ0NzU5OSIsIDEuMF0sIFsidDc2MDAiLCAxLjBdLCBbInQ3NjAxIiwgMS4wXSwgWyJ0NzYwMiIsIDEuMF0sIFsidDc2MDMiLCAxLjBdLCBbInQ3NjA0IiwgMS4wXSwgWyJ0NzYwNSIsIDEuMF0sIFsidDc2MDYiLCAxLjBdLCBbInQ3NjA3IiwgMS4wXSwgWyJ0NzYwOCIsIDEuMF0sIFsidDc2MDkiLCAxLjBdLCBbInQ3NjEwIiwgMS4wXSwgWyJ0NzYxMSIsIDEuMF0sIFsidDc2MTIiLCAxLjBdLCBbInQ3NjEzIiwgMS4wXSwgWyJ0NzYxNCIsIDEuMF0sIFsidDc2MTUiLCAxLjBdLCBbInQ3NjE2IiwgMS4wXSwgWyJ0NzYxNyIsIDEuMF0sIFsidDc2MTgiLCAxLjBdLCBbInQ3NjE5IiwgMS4wXSwgWyJ0NzYyMCIsIDEuMF0sIFsidDc2MjEiLCAxLjBdLCBbInQ3NjIyIiwgMS4wXSwgWyJ0NzYyMyIsIDEuMF0sIFsidDc2MjQiLCAxLjBdLCBbInQ3NjI1IiwgMS4wXSwgWyJ0NzYyNiIsIDEuMF0sIFsidDc2MjciLCAxLjBdLCBbInQ3NjI4IiwgMS4wXSwgWyJ0NzYyOSIsIDEuMF0sIFsidDc2MzAiLCAxLjBdLCBbInQ3NjMxIiwgMS4wXSwgWyJ0NzYzMiIsIDEuMF0sIFsidDc2MzMiLCAxLjBdLCBbInQ3NjM0IiwgMS4wXSwgWyJ0NzYzNSIsIDEuMF0sIFsidDc2MzYiLCAxLjBdLCBbInQ3NjM3IiwgMS4wXSwgWyJ0NzYzOCIsIDEuMF0sIFsidDc2MzkiLCAxLjBdLCBbInQ3NjQwIiwgMS4wXSwgWyJ0NzY0MSIsIDEuMF0sIFsidDc2NDIiLCAxLjBdLCBbInQ3NjQzIiwgMS4wXSwgWyJ0NzY0NCIsIDEuMF0sIFsidDc2NDUiLCAxLjBdLCBbInQ3NjQ2IiwgMS4wXSwgWyJ0NzY0NyIsIDEuMF0sIFsidDc2NDgiLCAxLjBdLCBbInQ3NjQ5IiwgMS4wXSwgWyJ0NzY1MCIsIDEuMF0sIFsidDc2NTEiLCAxLjBdLCBbInQ3NjUyIiwgMS4wXSwgWyJ0NzY1MyIsIDEuMF0sIFsidDc2NTQiLCAxLjBdLCBbInQ3NjU1IiwgMS4wXSwgWyJ0NzY1NiIsIDEuMF0sIFsidDc2NTciLCAxLjBdLCBbInQ3NjU4IiwgMS4wXSwgWyJ0NzY1OSIsIDEuMF0sIFsidDc2NjAiLCAxLjBdLCBbInQ3NjYxIiwgMS4wXSwgWyJ0NzY2MiIsIDEuMF0sIFsidDc2NjMiLCAxLjBdLCBbInQ3NjY0IiwgMS4wXSwgWyJ0NzY2NSIsIDEuMF0sIFsidDc2NjYiLCAxLjBdLCBbInQ3NjY3IiwgMS4wXSwgWyJ0NzY2OCIsIDEuMF0sIFsidDc2NjkiLCAxLjBdLCBbInQ3NjcwIiwgMS4wXSwgWyJ0NzY3MSIsIDEuMF0sIFsidDc2NzIiLCAxLjBdLCBbInQ3NjczIiwgMS4wXSwgWyJ0NzY3NCIsIDEuMF0sIFsidDc2NzUiLCAxLjBdLCBbInQ3Njc2IiwgMS4wXSwgWyJ0NzY3NyIsIDEuMF0sIFsidDc2NzgiLCAxLjBdLCBbInQ3Njc5IiwgMS4wXSwgWyJ0NzY4MCIsIDEuMF0sIFsidDc2ODEiLCAxLjBdLCBbInQ3NjgyIiwgMS4wXSwgWyJ0NzY4MyIsIDEuMF0sIFsidDc2ODQiLCAxLjBdLCBbInQ3Njg1IiwgMS4wXSwgWyJ0NzY4NiIsIDEuMF0sIFsidDc2ODciLCAxLjBdLCBbInQ3Njg4IiwgMS4wXSwgWyJ0NzY4OSIsIDEuMF0sIFsidDc2OTAiLCAxLjBdLCBbInQ3NjkxIiwgMS4wXSwgWyJ0NzY5MiIsIDEuMF0sIFsidDc2OTMiLCAxLjBdLCBbInQ3Njk0IiwgMS4wXSwgWyJ0NzY5NSIsIDEuMF0sIFsidDc2OTYiLCAxLjBdLCBbInQ3Njk3IiwgMS4wXSwgWyJ0NzY5OCIsIDEuMF0sIFsidDc2OTkiLCAxLjBdLCBbInQ3NzAwIiwgMS4wXSwgWyJ0NzcwMSIsIDEuMF0sIFsidDc3MDIiLCAxLjBdLCBbInQ3NzAzIiwgMS4wXSwgWyJ0NzcwNCIsIDEuMF0sIFsidDc3MDUiLCAxLjBdLCBbInQ3NzA2IiwgMS4wXSwgWyJ0NzcwNyIsIDEuMF0sIFsidDc3MDgiLCAxLjBdLCBbInQ3NzA5IiwgMS4wXSwgWyJ0NzcxMCIsIDEuMF0sIFsidDc3MTEiLCAxLjBdLCBbInQ3NzEyIiwgMS4wXSwgWyJ0NzcxMyIsIDEuMF0sIFsidDc3MTQiLCAxLjBdLCBbInQ3NzE1IiwgMS4wXSwgWyJ0NzcxNiIsIDEuMF0sIFsidDc3MTciLCAxLjBdLCBbInQ3NzE4IiwgMS4wXSwgWyJ0NzcxOSIsIDEuMF0sIFsidDc3MjAiLCAxLjBdLCBbInQ3NzIxIiwgMS4wXSwgWyJ0NzcyMiIsIDEuMF0sIFsidDc3MjMiLCAxLjBdLCBbInQ3NzI0IiwgMS4wXSwgWyJ0NzcyNSIsIDEuMF0sIFsidDc3MjYiLCAxLjBdLCBbInQ3NzI3IiwgMS4wXSwgWyJ0NzcyOCIsIDEuMF0sIFsidDc3MjkiLCAxLjBdLCBbInQ3NzMwIiwgMS4wXSwgWyJ0NzczMSIsIDEuMF0sIFsidDc3MzIiLCAxLjBdLCBbInQ3NzMzIiwgMS4wXSwgWyJ0NzczNCIsIDEuMF0sIFsidDc3MzUiLCAxLjBdLCBbInQ3NzM2IiwgMS4wXSwgWyJ0NzczNyIsIDEuMF0sIFsidDc3MzgiLCAxLjBdLCBbInQ3NzM5IiwgMS4wXSwgWyJ0Nzc0MCIsIDEuMF0sIFsidDc3NDEiLCAxLjBdLCBbInQ3NzQyIiwgMS4wXSwgWyJ0Nzc0MyIsIDEuMF0sIFsidDc3NDQiLCAxLjBdLCBbInQ3NzQ1IiwgMS4wXSwgWyJ0Nzc0NiIsIDEuMF0sIFsidDc3NDciLCAxLjBdLCBbInQ3NzQ4IiwgMS4wXSwgWyJ0Nzc0OSIsIDEuMF0sIFsidDc3NTAiLCAxLjBdLCBbInQ3NzUxIiwgMS4wXSwgWyJ0Nzc1MiIsIDEuMF0sIFsidDc3NTMiLCAxLjBdLCBbInQ3NzU0IiwgMS4wXSwgWyJ0Nzc1NSIsIDEuMF0sIFsidDc3NTYiLCAxLjBdLCBbInQ3NzU3IiwgMS4wXSwgWyJ0Nzc1OCIsIDEuMF0sIFsidDc3NTkiLCAxLjBdLCBbInQ3NzYwIiwgMS4wXSwgWyJ0Nzc2MSIsIDEuMF0sIFsidDc3NjIiLCAxLjBdLCBbInQ3NzYzIiwgMS4wXSwgWyJ0Nzc2NCIsIDEuMF0sIFsidDc3NjUiLCAxLjBdLCBbInQ3NzY2IiwgMS4wXSwgWyJ0Nzc2NyIsIDEuMF0sIFsidDc3NjgiLCAxLjBdLCBbInQ3NzY5IiwgMS4wXSwgWyJ0Nzc3MCIsIDEuMF0sIFsidDc3NzEiLCAxLjBdLCBbInQ3NzcyIiwgMS4wXSwgWyJ0Nzc3MyIsIDEuMF0sIFsidDc3NzQiLCAxLjBdLCBbInQ3Nzc1IiwgMS4wXSwgWyJ0Nzc3NiIsIDEuMF0sIFsidDc3NzciLCAxLjBdLCBbInQ3Nzc4IiwgMS4wXSwgWyJ0Nzc3OSIsIDEuMF0sIFsidDc3ODAiLCAxLjBdLCBbInQ3NzgxIiwgMS4wXSwgWyJ0Nzc4MiIsIDEuMF0sIFsidDc3ODMiLCAxLjBdLCBbInQ3Nzg0IiwgMS4wXSwgWyJ0Nzc4NSIsIDEuMF0sIFsidDc3ODYiLCAxLjBdLCBbInQ3Nzg3IiwgMS4wXSwgWyJ0Nzc4OCIsIDEuMF0sIFsidDc3ODkiLCAxLjBdLCBbInQ3NzkwIiwgMS4wXSwgWyJ0Nzc5MSIsIDEuMF0sIFsidDc3OTIiLCAxLjBdLCBbInQ3NzkzIiwgMS4wXSwgWyJ0Nzc5NCIsIDEuMF0sIFsidDc3OTUiLCAxLjBdLCBbInQ3Nzk2IiwgMS4wXSwgWyJ0Nzc5NyIsIDEuMF0sIFsidDc3OTgiLCAxLjBdLCBbInQ3Nzk5IiwgMS4wXSwgWyJ0NzgwMCIsIDEuMF0sIFsidDc4MDEiLCAxLjBdLCBbInQ3ODAyIiwgMS4wXSwgWyJ0NzgwMyIsIDEuMF0sIFsidDc4MDQiLCAxLjBdLCBbInQ3ODA1IiwgMS4wXSwgWyJ0NzgwNiIsIDEuMF0sIFsidDc4MDciLCAxLjBdLCBbInQ3ODA4IiwgMS4wXSwgWyJ0NzgwOSIsIDEuMF0sIFsidDc4MTAiLCAxLjBdLCBbInQ3ODExIiwgMS4wXSwgWyJ0NzgxMiIsIDEuMF0sIFsidDc4MTMiLCAxLjBdLCBbInQ3ODE0IiwgMS4wXSwgWyJ0NzgxNSIsIDEuMF0sIFsidDc4MTYiLCAxLjBdLCBbInQ3ODE3IiwgMS4wXSwgWyJ0NzgxOCIsIDEuMF0sIFsidDc4MTkiLCAxLjBdLCBbInQ3ODIwIiwgMS4wXSwgWyJ0NzgyMSIsIDEuMF0sIFsidDc4MjIiLCAxLjBdLCBbInQ3ODIzIiwgMS4wXSwgWyJ0NzgyNCIsIDEuMF0sIFsidDc4MjUiLCAxLjBdLCBbInQ3ODI2IiwgMS4wXSwgWyJ0NzgyNyIsIDEuMF0sIFsidDc4MjgiLCAxLjBdLCBbInQ3ODI5IiwgMS4wXSwgWyJ0NzgzMCIsIDEuMF0sIFsidDc4MzEiLCAxLjBdLCBbInQ3ODMyIiwgMS4wXSwgWyJ0NzgzMyIsIDEuMF0sIFsidDc4MzQiLCAxLjBdLCBbInQ3ODM1IiwgMS4wXSwgWyJ0NzgzNiIsIDEuMF0sIFsidDc4MzciLCAxLjBdLCBbInQ3ODM4IiwgMS4wXSwgWyJ0NzgzOSIsIDEuMF0sIFsidDc4NDAiLCAxLjBdLCBbInQ3ODQxIiwgMS4wXSwgWyJ0Nzg0MiIsIDEuMF0sIFsidDc4NDMiLCAxLjBdLCBbInQ3ODQ0IiwgMS4wXSwgWyJ0Nzg0NSIsIDEuMF0sIFsidDc4NDYiLCAxLjBdLCBbInQ3ODQ3IiwgMS4wXSwgWyJ0Nzg0OCIsIDEuMF0sIFsidDc4NDkiLCAxLjBdLCBbInQ3ODUwIiwgMS4wXSwgWyJ0Nzg1MSIsIDEuMF0sIFsidDc4NTIiLCAxLjBdLCBbInQ3ODUzIiwgMS4wXSwgWyJ0Nzg1NCIsIDEuMF0sIFsidDc4NTUiLCAxLjBdLCBbInQ3ODU2IiwgMS4wXSwgWyJ0Nzg1NyIsIDEuMF0sIFsidDc4NTgiLCAxLjBdLCBbInQ3ODU5IiwgMS4wXSwgWyJ0Nzg2MCIsIDEuMF0sIFsidDc4NjEiLCAxLjBdLCBbInQ3ODYyIiwgMS4wXSwgWyJ0Nzg2MyIsIDEuMF0sIFsidDc4NjQiLCAxLjBdLCBbInQ3ODY1IiwgMS4wXSwgWyJ0Nzg2NiIsIDEuMF0sIFsidDc4NjciLCAxLjBdLCBbInQ3ODY4IiwgMS4wXSwgWyJ0Nzg2OSIsIDEuMF0sIFsidDc4NzAiLCAxLjBdLCBbInQ3ODcxIiwgMS4wXSwgWyJ0Nzg3MiIsIDEuMF0sIFsidDc4NzMiLCAxLjBdLCBbInQ3ODc0IiwgMS4wXSwgWyJ0Nzg3NSIsIDEuMF0sIFsidDc4NzYiLCAxLjBdLCBbInQ3ODc3IiwgMS4wXSwgWyJ0Nzg3OCIsIDEuMF0sIFsidDc4NzkiLCAxLjBdLCBbInQ3ODgwIiwgMS4wXSwgWyJ0Nzg4MSIsIDEuMF0sIFsidDc4ODIiLCAxLjBdLCBbInQ3ODgzIiwgMS4wXSwgWyJ0Nzg4NCIsIDEuMF0sIFsidDc4ODUiLCAxLjBdLCBbInQ3ODg2IiwgMS4wXSwgWyJ0Nzg4NyIsIDEuMF0sIFsidDc4ODgiLCAxLjBdLCBbInQ3ODg5IiwgMS4wXSwgWyJ0Nzg5MCIsIDEuMF0sIFsidDc4OTEiLCAxLjBdLCBbInQ3ODkyIiwgMS4wXSwgWyJ0Nzg5MyIsIDEuMF0sIFsidDc4OTQiLCAxLjBdLCBbInQ3ODk1IiwgMS4wXSwgWyJ0Nzg5NiIsIDEuMF0sIFsidDc4OTciLCAxLjBdLCBbInQ3ODk4IiwgMS4wXSwgWyJ0Nzg5OSIsIDEuMF0sIFsidDc5MDAiLCAxLjBdLCBbInQ3OTAxIiwgMS4wXSwgWyJ0NzkwMiIsIDEuMF0sIFsidDc5MDMiLCAxLjBdLCBbInQ3OTA0IiwgMS4wXSwgWyJ0NzkwNSIsIDEuMF0sIFsidDc5MDYiLCAxLjBdLCBbInQ3OTA3IiwgMS4wXSwgWyJ0NzkwOCIsIDEuMF0sIFsidDc5MDkiLCAxLjBdLCBbInQ3OTEwIiwgMS4wXSwgWyJ0NzkxMSIsIDEuMF0sIFsidDc5MTIiLCAxLjBdLCBbInQ3OTEzIiwgMS4wXSwgWyJ0NzkxNCIsIDEuMF0sIFsidDc5MTUiLCAxLjBdLCBbInQ3OTE2IiwgMS4wXSwgWyJ0NzkxNyIsIDEuMF0sIFsidDc5MTgiLCAxLjBdLCBbInQ3OTE5IiwgMS4wXSwgWyJ0NzkyMCIsIDEuMF0sIFsidDc5MjEiLCAxLjBdLCBbInQ3OTIyIiwgMS4wXSwgWyJ0NzkyMyIsIDEuMF0sIFsidDc5MjQiLCAxLjBdLCBbInQ3OTI1IiwgMS4wXSwgWyJ0NzkyNiIsIDEuMF0sIFsidDc5MjciLCAxLjBdLCBbInQ3OTI4IiwgMS4wXSwgWyJ0NzkyOSIsIDEuMF0sIFsidDc5MzAiLCAxLjBdLCBbInQ3OTMxIiwgMS4wXSwgWyJ0NzkzMiIsIDEuMF0sIFsidDc5MzMiLCAxLjBdLCBbInQ3OTM0IiwgMS4wXSwgWyJ0NzkzNSIsIDEuMF0sIFsidDc5MzYiLCAxLjBdLCBbInQ3OTM3IiwgMS4wXSwgWyJ0NzkzOCIsIDEuMF0sIFsidDc5MzkiLCAxLjBdLCBbInQ3OTQwIiwgMS4wXSwgWyJ0Nzk0MSIsIDEuMF0sIFsidDc5NDIiLCAxLjBdLCBbInQ3OTQzIiwgMS4wXSwgWyJ0Nzk0NCIsIDEuMF0sIFsidDc5NDUiLCAxLjBdLCBbInQ3OTQ2IiwgMS4wXSwgWyJ0Nzk0NyIsIDEuMF0sIFsidDc5NDgiLCAxLjBdLCBbInQ3OTQ5IiwgMS4wXSwgWyJ0Nzk1MCIsIDEuMF0sIFsidDc5NTEiLCAxLjBdLCBbInQ3OTUyIiwgMS4wXSwgWyJ0Nzk1MyIsIDEuMF0sIFsidDc5NTQiLCAxLjBdLCBbInQ3OTU1IiwgMS4wXSwgWyJ0Nzk1NiIsIDEuMF0sIFsidDc5NTciLCAxLjBdLCBbInQ3OTU4IiwgMS4wXSwgWyJ0Nzk1OSIsIDEuMF0sIFsidDc5NjAiLCAxLjBdLCBbInQ3OTYxIiwgMS4wXSwgWyJ0Nzk2MiIsIDEuMF0sIFsidDc5NjMiLCAxLjBdLCBbInQ3OTY0IiwgMS4wXSwgWyJ0Nzk2NSIsIDEuMF0sIFsidDc5NjYiLCAxLjBdLCBbInQ3OTY3IiwgMS4wXSwgWyJ0Nzk2OCIsIDEuMF0sIFsidDc5NjkiLCAxLjBdLCBbInQ3OTcwIiwgMS4wXSwgWyJ0Nzk3MSIsIDEuMF0sIFsidDc5NzIiLCAxLjBdLCBbInQ3OTczIiwgMS4wXSwgWyJ0Nzk3NCIsIDEuMF0sIFsidDc5NzUiLCAxLjBdLCBbInQ3OTc2IiwgMS4wXSwgWyJ0Nzk3NyIsIDEuMF0sIFsidDc5NzgiLCAxLjBdLCBbInQ3OTc5IiwgMS4wXSwgWyJ0Nzk4MCIsIDEuMF0sIFsidDc5ODEiLCAxLjBdLCBbInQ3OTgyIiwgMS4wXSwgWyJ0Nzk4MyIsIDEuMF0sIFsidDc5ODQiLCAxLjBdLCBbInQ3OTg1IiwgMS4wXSwgWyJ0Nzk4NiIsIDEuMF0sIFsidDc5ODciLCAxLjBdLCBbInQ3OTg4IiwgMS4wXSwgWyJ0Nzk4OSIsIDEuMF0sIFsidDc5OTAiLCAxLjBdLCBbInQ3OTkxIiwgMS4wXSwgWyJ0Nzk5MiIsIDEuMF0sIFsidDc5OTMiLCAxLjBdLCBbInQ3OTk0IiwgMS4wXSwgWyJ0Nzk5NSIsIDEuMF0sIFsidDc5OTYiLCAxLjBdLCBbInQ3OTk3IiwgMS4wXSwgWyJ0Nzk5OCIsIDEuMF0sIFsidDc5OTkiLCAxLjBdLCBbInQ4MDAwIiwgMS4wXSwgWyJ0ODAwMSIsIDEuMF0sIFsidDgwMDIiLCAxLjBdLCBbInQ4MDAzIiwgMS4wXSwgWyJ0ODAwNCIsIDEuMF0sIFsidDgwMDUiLCAxLjBdLCBbInQ4MDA2IiwgMS4wXSwgWyJ0ODAwNyIsIDEuMF0sIFsidDgwMDgiLCAxLjBdLCBbInQ4MDA5IiwgMS4wXSwgWyJ0ODAxMCIsIDEuMF0sIFsidDgwMTEiLCAxLjBdLCBbInQ4MDEyIiwgMS4wXSwgWyJ0ODAxMyIsIDEuMF0sIFsidDgwMTQiLCAxLjBdLCBbInQ4MDE1IiwgMS4wXSwgWyJ0ODAxNiIsIDEuMF0sIFsidDgwMTciLCAxLjBdLCBbInQ4MDE4IiwgMS4wXSwgWyJ0ODAxOSIsIDEuMF0sIFsidDgwMjAiLCAxLjBdLCBbInQ4MDIxIiwgMS4wXSwgWyJ0ODAyMiIsIDEuMF0sIFsidDgwMjMiLCAxLjBdLCBbInQ4MDI0IiwgMS4wXSwgWyJ0ODAyNSIsIDEuMF0sIFsidDgwMjYiLCAxLjBdLCBbInQ4MDI3IiwgMS4wXSwgWyJ0ODAyOCIsIDEuMF0sIFsidDgwMjkiLCAxLjBdLCBbInQ4MDMwIiwgMS4wXSwgWyJ0ODAzMSIsIDEuMF0sIFsidDgwMzIiLCAxLjBdLCBbInQ4MDMzIiwgMS4wXSwgWyJ0ODAzNCIsIDEuMF0sIFsidDgwMzUiLCAxLjBdLCBbInQ4MDM2IiwgMS4wXSwgWyJ0ODAzNyIsIDEuMF0sIFsidDgwMzgiLCAxLjBdLCBbInQ4MDM5IiwgMS4wXSwgWyJ0ODA0MCIsIDEuMF0sIFsidDgwNDEiLCAxLjBdLCBbInQ4MDQyIiwgMS4wXSwgWyJ0ODA0MyIsIDEuMF0sIFsidDgwNDQiLCAxLjBdLCBbInQ4MDQ1IiwgMS4wXSwgWyJ0ODA0NiIsIDEuMF0sIFsidDgwNDciLCAxLjBdLCBbInQ4MDQ4IiwgMS4wXSwgWyJ0ODA0OSIsIDEuMF0sIFsidDgwNTAiLCAxLjBdLCBbInQ4MDUxIiwgMS4wXSwgWyJ0ODA1MiIsIDEuMF0sIFsidDgwNTMiLCAxLjBdLCBbInQ4MDU0IiwgMS4wXSwgWyJ0ODA1NSIsIDEuMF0sIFsidDgwNTYiLCAxLjBdLCBbInQ4MDU3IiwgMS4wXSwgWyJ0ODA1OCIsIDEuMF0sIFsidDgwNTkiLCAxLjBdLCBbInQ4MDYwIiwgMS4wXSwgWyJ0ODA2MSIsIDEuMF0sIFsidDgwNjIiLCAxLjBdLCBbInQ4MDYzIiwgMS4wXSwgWyJ0ODA2NCIsIDEuMF0sIFsidDgwNjUiLCAxLjBdLCBbInQ4MDY2IiwgMS4wXSwgWyJ0ODA2NyIsIDEuMF0sIFsidDgwNjgiLCAxLjBdLCBbInQ4MDY5IiwgMS4wXSwgWyJ0ODA3MCIsIDEuMF0sIFsidDgwNzEiLCAxLjBdLCBbInQ4MDcyIiwgMS4wXSwgWyJ0ODA3MyIsIDEuMF0sIFsidDgwNzQiLCAxLjBdLCBbInQ4MDc1IiwgMS4wXSwgWyJ0ODA3NiIsIDEuMF0sIFsidDgwNzciLCAxLjBdLCBbInQ4MDc4IiwgMS4wXSwgWyJ0ODA3OSIsIDEuMF0sIFsidDgwODAiLCAxLjBdLCBbInQ4MDgxIiwgMS4wXSwgWyJ0ODA4MiIsIDEuMF0sIFsidDgwODMiLCAxLjBdLCBbInQ4MDg0IiwgMS4wXSwgWyJ0ODA4NSIsIDEuMF0sIFsidDgwODYiLCAxLjBdLCBbInQ4MDg3IiwgMS4wXSwgWyJ0ODA4OCIsIDEuMF0sIFsidDgwODkiLCAxLjBdLCBbInQ4MDkwIiwgMS4wXSwgWyJ0ODA5MSIsIDEuMF0sIFsidDgwOTIiLCAxLjBdLCBbInQ4MDkzIiwgMS4wXSwgWyJ0ODA5NCIsIDEuMF0sIFsidDgwOTUiLCAxLjBdLCBbInQ4MDk2IiwgMS4wXSwgWyJ0ODA5NyIsIDEuMF0sIFsidDgwOTgiLCAxLjBdLCBbInQ4MDk5IiwgMS4wXSwgWyJ0ODEwMCIsIDEuMF0sIFsidDgxMDEiLCAxLjBdLCBbInQ4MTAyIiwgMS4wXSwgWyJ0ODEwMyIsIDEuMF0sIFsidDgxMDQiLCAxLjBdLCBbInQ4MTA1IiwgMS4wXSwgWyJ0ODEwNiIsIDEuMF0sIFsidDgxMDciLCAxLjBdLCBbInQ4MTA4IiwgMS4wXSwgWyJ0ODEwOSIsIDEuMF0sIFsidDgxMTAiLCAxLjBdLCBbInQ4MTExIiwgMS4wXSwgWyJ0ODExMiIsIDEuMF0sIFsidDgxMTMiLCAxLjBdLCBbInQ4MTE0IiwgMS4wXSwgWyJ0ODExNSIsIDEuMF0sIFsidDgxMTYiLCAxLjBdLCBbInQ4MTE3IiwgMS4wXSwgWyJ0ODExOCIsIDEuMF0sIFsidDgxMTkiLCAxLjBdLCBbInQ4MTIwIiwgMS4wXSwgWyJ0ODEyMSIsIDEuMF0sIFsidDgxMjIiLCAxLjBdLCBbInQ4MTIzIiwgMS4wXSwgWyJ0ODEyNCIsIDEuMF0sIFsidDgxMjUiLCAxLjBdLCBbInQ4MTI2IiwgMS4wXSwgWyJ0ODEyNyIsIDEuMF0sIFsidDgxMjgiLCAxLjBdLCBbInQ4MTI5IiwgMS4wXSwgWyJ0ODEzMCIsIDEuMF0sIFsidDgxMzEiLCAxLjBdLCBbInQ4MTMyIiwgMS4wXSwgWyJ0ODEzMyIsIDEuMF0sIFsidDgxMzQiLCAxLjBdLCBbInQ4MTM1IiwgMS4wXSwgWyJ0ODEzNiIsIDEuMF0sIFsidDgxMzciLCAxLjBdLCBbInQ4MTM4IiwgMS4wXSwgWyJ0ODEzOSIsIDEuMF0sIFsidDgxNDAiLCAxLjBdLCBbInQ4MTQxIiwgMS4wXSwgWyJ0ODE0MiIsIDEuMF0sIFsidDgxNDMiLCAxLjBdLCBbInQ4MTQ0IiwgMS4wXSwgWyJ0ODE0NSIsIDEuMF0sIFsidDgxNDYiLCAxLjBdLCBbInQ4MTQ3IiwgMS4wXSwgWyJ0ODE0OCIsIDEuMF0sIFsidDgxNDkiLCAxLjBdLCBbInQ4MTUwIiwgMS4wXSwgWyJ0ODE1MSIsIDEuMF0sIFsidDgxNTIiLCAxLjBdLCBbInQ4MTUzIiwgMS4wXSwgWyJ0ODE1NCIsIDEuMF0sIFsidDgxNTUiLCAxLjBdLCBbInQ4MTU2IiwgMS4wXSwgWyJ0ODE1NyIsIDEuMF0sIFsidDgxNTgiLCAxLjBdLCBbInQ4MTU5IiwgMS4wXSwgWyJ0ODE2MCIsIDEuMF0sIFsidDgxNjEiLCAxLjBdLCBbInQ4MTYyIiwgMS4wXSwgWyJ0ODE2MyIsIDEuMF0sIFsidDgxNjQiLCAxLjBdLCBbInQ4MTY1IiwgMS4wXSwgWyJ0ODE2NiIsIDEuMF0sIFsidDgxNjciLCAxLjBdLCBbInQ4MTY4IiwgMS4wXSwgWyJ0ODE2OSIsIDEuMF0sIFsidDgxNzAiLCAxLjBdLCBbInQ4MTcxIiwgMS4wXSwgWyJ0ODE3MiIsIDEuMF0sIFsidDgxNzMiLCAxLjBdLCBbInQ4MTc0IiwgMS4wXSwgWyJ0ODE3NSIsIDEuMF0sIFsidDgxNzYiLCAxLjBdLCBbInQ4MTc3IiwgMS4wXSwgWyJ0ODE3OCIsIDEuMF0sIFsidDgxNzkiLCAxLjBdLCBbInQ4MTgwIiwgMS4wXSwgWyJ0ODE4MSIsIDEuMF0sIFsidDgxODIiLCAxLjBdLCBbInQ4MTgzIiwgMS4wXSwgWyJ0ODE4NCIsIDEuMF0sIFsidDgxODUiLCAxLjBdLCBbInQ4MTg2IiwgMS4wXSwgWyJ0ODE4NyIsIDEuMF0sIFsidDgxODgiLCAxLjBdLCBbInQ4MTg5IiwgMS4wXSwgWyJ0ODE5MCIsIDEuMF0sIFsidDgxOTEiLCAxLjBdLCBbInQ4MTkyIiwgMS4wXSwgWyJ0ODE5MyIsIDEuMF0sIFsidDgxOTQiLCAxLjBdLCBbInQ4MTk1IiwgMS4wXSwgWyJ0ODE5NiIsIDEuMF0sIFsidDgxOTciLCAxLjBdLCBbInQ4MTk4IiwgMS4wXSwgWyJ0ODE5OSIsIDEuMF0sIFsidDgyMDAiLCAxLjBdLCBbInQ4MjAxIiwgMS4wXSwgWyJ0ODIwMiIsIDEuMF0sIFsidDgyMDMiLCAxLjBdLCBbInQ4MjA0IiwgMS4wXSwgWyJ0ODIwNSIsIDEuMF0sIFsidDgyMDYiLCAxLjBdLCBbInQ4MjA3IiwgMS4wXSwgWyJ0ODIwOCIsIDEuMF0sIFsidDgyMDkiLCAxLjBdLCBbInQ4MjEwIiwgMS4wXSwgWyJ0ODIxMSIsIDEuMF0sIFsidDgyMTIiLCAxLjBdLCBbInQ4MjEzIiwgMS4wXSwgWyJ0ODIxNCIsIDEuMF0sIFsidDgyMTUiLCAxLjBdLCBbInQ4MjE2IiwgMS4wXSwgWyJ0ODIxNyIsIDEuMF0sIFsidDgyMTgiLCAxLjBdLCBbInQ4MjE5IiwgMS4wXSwgWyJ0ODIyMCIsIDEuMF0sIFsidDgyMjEiLCAxLjBdLCBbInQ4MjIyIiwgMS4wXSwgWyJ0ODIyMyIsIDEuMF0sIFsidDgyMjQiLCAxLjBdLCBbInQ4MjI1IiwgMS4wXSwgWyJ0ODIyNiIsIDEuMF0sIFsidDgyMjciLCAxLjBdLCBbInQ4MjI4IiwgMS4wXSwgWyJ0ODIyOSIsIDEuMF0sIFsidDgyMzAiLCAxLjBdLCBbInQ4MjMxIiwgMS4wXSwgWyJ0ODIzMiIsIDEuMF0sIFsidDgyMzMiLCAxLjBdLCBbInQ4MjM0IiwgMS4wXSwgWyJ0ODIzNSIsIDEuMF0sIFsidDgyMzYiLCAxLjBdLCBbInQ4MjM3IiwgMS4wXSwgWyJ0ODIzOCIsIDEuMF0sIFsidDgyMzkiLCAxLjBdLCBbInQ4MjQwIiwgMS4wXSwgWyJ0ODI0MSIsIDEuMF0sIFsidDgyNDIiLCAxLjBdLCBbInQ4MjQzIiwgMS4wXSwgWyJ0ODI0NCIsIDEuMF0sIFsidDgyNDUiLCAxLjBdLCBbInQ4MjQ2IiwgMS4wXSwgWyJ0ODI0NyIsIDEuMF0sIFsidDgyNDgiLCAxLjBdLCBbInQ4MjQ5IiwgMS4wXSwgWyJ0ODI1MCIsIDEuMF0sIFsidDgyNTEiLCAxLjBdLCBbInQ4MjUyIiwgMS4wXSwgWyJ0ODI1MyIsIDEuMF0sIFsidDgyNTQiLCAxLjBdLCBbInQ4MjU1IiwgMS4wXSwgWyJ0ODI1NiIsIDEuMF0sIFsidDgyNTciLCAxLjBdLCBbInQ4MjU4IiwgMS4wXSwgWyJ0ODI1OSIsIDEuMF0sIFsidDgyNjAiLCAxLjBdLCBbInQ4MjYxIiwgMS4wXSwgWyJ0ODI2MiIsIDEuMF0sIFsidDgyNjMiLCAxLjBdLCBbInQ4MjY0IiwgMS4wXSwgWyJ0ODI2NSIsIDEuMF0sIFsidDgyNjYiLCAxLjBdLCBbInQ4MjY3IiwgMS4wXSwgWyJ0ODI2OCIsIDEuMF0sIFsidDgyNjkiLCAxLjBdLCBbInQ4MjcwIiwgMS4wXSwgWyJ0ODI3MSIsIDEuMF0sIFsidDgyNzIiLCAxLjBdLCBbInQ4MjczIiwgMS4wXSwgWyJ0ODI3NCIsIDEuMF0sIFsidDgyNzUiLCAxLjBdLCBbInQ4Mjc2IiwgMS4wXSwgWyJ0ODI3NyIsIDEuMF0sIFsidDgyNzgiLCAxLjBdLCBbInQ4Mjc5IiwgMS4wXSwgWyJ0ODI4MCIsIDEuMF0sIFsidDgyODEiLCAxLjBdLCBbInQ4MjgyIiwgMS4wXSwgWyJ0ODI4MyIsIDEuMF0sIFsidDgyODQiLCAxLjBdLCBbInQ4Mjg1IiwgMS4wXSwgWyJ0ODI4NiIsIDEuMF0sIFsidDgyODciLCAxLjBdLCBbInQ4Mjg4IiwgMS4wXSwgWyJ0ODI4OSIsIDEuMF0sIFsidDgyOTAiLCAxLjBdLCBbInQ4MjkxIiwgMS4wXSwgWyJ0ODI5MiIsIDEuMF0sIFsidDgyOTMiLCAxLjBdLCBbInQ4Mjk0IiwgMS4wXSwgWyJ0ODI5NSIsIDEuMF0sIFsidDgyOTYiLCAxLjBdLCBbInQ4Mjk3IiwgMS4wXSwgWyJ0ODI5OCIsIDEuMF0sIFsidDgyOTkiLCAxLjBdLCBbInQ4MzAwIiwgMS4wXSwgWyJ0ODMwMSIsIDEuMF0sIFsidDgzMDIiLCAxLjBdLCBbInQ4MzAzIiwgMS4wXSwgWyJ0ODMwNCIsIDEuMF0sIFsidDgzMDUiLCAxLjBdLCBbInQ4MzA2IiwgMS4wXSwgWyJ0ODMwNyIsIDEuMF0sIFsidDgzMDgiLCAxLjBdLCBbInQ4MzA5IiwgMS4wXSwgWyJ0ODMxMCIsIDEuMF0sIFsidDgzMTEiLCAxLjBdLCBbInQ4MzEyIiwgMS4wXSwgWyJ0ODMxMyIsIDEuMF0sIFsidDgzMTQiLCAxLjBdLCBbInQ4MzE1IiwgMS4wXSwgWyJ0ODMxNiIsIDEuMF0sIFsidDgzMTciLCAxLjBdLCBbInQ4MzE4IiwgMS4wXSwgWyJ0ODMxOSIsIDEuMF0sIFsidDgzMjAiLCAxLjBdLCBbInQ4MzIxIiwgMS4wXSwgWyJ0ODMyMiIsIDEuMF0sIFsidDgzMjMiLCAxLjBdLCBbInQ4MzI0IiwgMS4wXSwgWyJ0ODMyNSIsIDEuMF0sIFsidDgzMjYiLCAxLjBdLCBbInQ4MzI3IiwgMS4wXSwgWyJ0ODMyOCIsIDEuMF0sIFsidDgzMjkiLCAxLjBdLCBbInQ4MzMwIiwgMS4wXSwgWyJ0ODMzMSIsIDEuMF0sIFsidDgzMzIiLCAxLjBdLCBbInQ4MzMzIiwgMS4wXSwgWyJ0ODMzNCIsIDEuMF0sIFsidDgzMzUiLCAxLjBdLCBbInQ4MzM2IiwgMS4wXSwgWyJ0ODMzNyIsIDEuMF0sIFsidDgzMzgiLCAxLjBdLCBbInQ4MzM5IiwgMS4wXSwgWyJ0ODM0MCIsIDEuMF0sIFsidDgzNDEiLCAxLjBdLCBbInQ4MzQyIiwgMS4wXSwgWyJ0ODM0MyIsIDEuMF0sIFsidDgzNDQiLCAxLjBdLCBbInQ4MzQ1IiwgMS4wXSwgWyJ0ODM0NiIsIDEuMF0sIFsidDgzNDciLCAxLjBdLCBbInQ4MzQ4IiwgMS4wXSwgWyJ0ODM0OSIsIDEuMF0sIFsidDgzNTAiLCAxLjBdLCBbInQ4MzUxIiwgMS4wXSwgWyJ0ODM1MiIsIDEuMF0sIFsidDgzNTMiLCAxLjBdLCBbInQ4MzU0IiwgMS4wXSwgWyJ0ODM1NSIsIDEuMF0sIFsidDgzNTYiLCAxLjBdLCBbInQ4MzU3IiwgMS4wXSwgWyJ0ODM1OCIsIDEuMF0sIFsidDgzNTkiLCAxLjBdLCBbInQ4MzYwIiwgMS4wXSwgWyJ0ODM2MSIsIDEuMF0sIFsidDgzNjIiLCAxLjBdLCBbInQ4MzYzIiwgMS4wXSwgWyJ0ODM2NCIsIDEuMF0sIFsidDgzNjUiLCAxLjBdLCBbInQ4MzY2IiwgMS4wXSwgWyJ0ODM2NyIsIDEuMF0sIFsidDgzNjgiLCAxLjBdLCBbInQ4MzY5IiwgMS4wXSwgWyJ0ODM3MCIsIDEuMF0sIFsidDgzNzEiLCAxLjBdLCBbInQ4MzcyIiwgMS4wXSwgWyJ0ODM3MyIsIDEuMF0sIFsidDgzNzQiLCAxLjBdLCBbInQ4Mzc1IiwgMS4wXSwgWyJ0ODM3NiIsIDEuMF0sIFsidDgzNzciLCAxLjBdLCBbInQ4Mzc4IiwgMS4wXSwgWyJ0ODM3OSIsIDEuMF0sIFsidDgzODAiLCAxLjBdLCBbInQ4MzgxIiwgMS4wXSwgWyJ0ODM4MiIsIDEuMF0sIFsidDgzODMiLCAxLjBdLCBbInQ4Mzg0IiwgMS4wXSwgWyJ0ODM4NSIsIDEuMF0sIFsidDgzODYiLCAxLjBdLCBbInQ4Mzg3IiwgMS4wXSwgWyJ0ODM4OCIsIDEuMF0sIFsidDgzODkiLCAxLjBdLCBbInQ4MzkwIiwgMS4wXSwgWyJ0ODM5MSIsIDEuMF0sIFsidDgzOTIiLCAxLjBdLCBbInQ4MzkzIiwgMS4wXSwgWyJ0ODM5NCIsIDEuMF0sIFsidDgzOTUiLCAxLjBdLCBbInQ4Mzk2IiwgMS4wXSwgWyJ0ODM5NyIsIDEuMF0sIFsidDgzOTgiLCAxLjBdLCBbInQ4Mzk5IiwgMS4wXSwgWyJ0ODQwMCIsIDEuMF0sIFsidDg0MDEiLCAxLjBdLCBbInQ4NDAyIiwgMS4wXSwgWyJ0ODQwMyIsIDEuMF0sIFsidDg0MDQiLCAxLjBdLCBbInQ4NDA1IiwgMS4wXSwgWyJ0ODQwNiIsIDEuMF0sIFsidDg0MDciLCAxLjBdLCBbInQ4NDA4IiwgMS4wXSwgWyJ0ODQwOSIsIDEuMF0sIFsidDg0MTAiLCAxLjBdLCBbInQ4NDExIiwgMS4wXSwgWyJ0ODQxMiIsIDEuMF0sIFsidDg0MTMiLCAxLjBdLCBbInQ4NDE0IiwgMS4wXSwgWyJ0ODQxNSIsIDEuMF0sIFsidDg0MTYiLCAxLjBdLCBbInQ4NDE3IiwgMS4wXSwgWyJ0ODQxOCIsIDEuMF0sIFsidDg0MTkiLCAxLjBdLCBbInQ4NDIwIiwgMS4wXSwgWyJ0ODQyMSIsIDEuMF0sIFsidDg0MjIiLCAxLjBdLCBbInQ4NDIzIiwgMS4wXSwgWyJ0ODQyNCIsIDEuMF0sIFsidDg0MjUiLCAxLjBdLCBbInQ4NDI2IiwgMS4wXSwgWyJ0ODQyNyIsIDEuMF0sIFsidDg0MjgiLCAxLjBdLCBbInQ4NDI5IiwgMS4wXSwgWyJ0ODQzMCIsIDEuMF0sIFsidDg0MzEiLCAxLjBdLCBbInQ4NDMyIiwgMS4wXSwgWyJ0ODQzMyIsIDEuMF0sIFsidDg0MzQiLCAxLjBdLCBbInQ4NDM1IiwgMS4wXSwgWyJ0ODQzNiIsIDEuMF0sIFsidDg0MzciLCAxLjBdLCBbInQ4NDM4IiwgMS4wXSwgWyJ0ODQzOSIsIDEuMF0sIFsidDg0NDAiLCAxLjBdLCBbInQ4NDQxIiwgMS4wXSwgWyJ0ODQ0MiIsIDEuMF0sIFsidDg0NDMiLCAxLjBdLCBbInQ4NDQ0IiwgMS4wXSwgWyJ0ODQ0NSIsIDEuMF0sIFsidDg0NDYiLCAxLjBdLCBbInQ4NDQ3IiwgMS4wXSwgWyJ0ODQ0OCIsIDEuMF0sIFsidDg0NDkiLCAxLjBdLCBbInQ4NDUwIiwgMS4wXSwgWyJ0ODQ1MSIsIDEuMF0sIFsidDg0NTIiLCAxLjBdLCBbInQ4NDUzIiwgMS4wXSwgWyJ0ODQ1NCIsIDEuMF0sIFsidDg0NTUiLCAxLjBdLCBbInQ4NDU2IiwgMS4wXSwgWyJ0ODQ1NyIsIDEuMF0sIFsidDg0NTgiLCAxLjBdLCBbInQ4NDU5IiwgMS4wXSwgWyJ0ODQ2MCIsIDEuMF0sIFsidDg0NjEiLCAxLjBdLCBbInQ4NDYyIiwgMS4wXSwgWyJ0ODQ2MyIsIDEuMF0sIFsidDg0NjQiLCAxLjBdLCBbInQ4NDY1IiwgMS4wXSwgWyJ0ODQ2NiIsIDEuMF0sIFsidDg0NjciLCAxLjBdLCBbInQ4NDY4IiwgMS4wXSwgWyJ0ODQ2OSIsIDEuMF0sIFsidDg0NzAiLCAxLjBdLCBbInQ4NDcxIiwgMS4wXSwgWyJ0ODQ3MiIsIDEuMF0sIFsidDg0NzMiLCAxLjBdLCBbInQ4NDc0IiwgMS4wXSwgWyJ0ODQ3NSIsIDEuMF0sIFsidDg0NzYiLCAxLjBdLCBbInQ4NDc3IiwgMS4wXSwgWyJ0ODQ3OCIsIDEuMF0sIFsidDg0NzkiLCAxLjBdLCBbInQ4NDgwIiwgMS4wXSwgWyJ0ODQ4MSIsIDEuMF0sIFsidDg0ODIiLCAxLjBdLCBbInQ4NDgzIiwgMS4wXSwgWyJ0ODQ4NCIsIDEuMF0sIFsidDg0ODUiLCAxLjBdLCBbInQ4NDg2IiwgMS4wXSwgWyJ0ODQ4NyIsIDEuMF0sIFsidDg0ODgiLCAxLjBdLCBbInQ4NDg5IiwgMS4wXSwgWyJ0ODQ5MCIsIDEuMF0sIFsidDg0OTEiLCAxLjBdLCBbInQ4NDkyIiwgMS4wXSwgWyJ0ODQ5MyIsIDEuMF0sIFsidDg0OTQiLCAxLjBdLCBbInQ4NDk1IiwgMS4wXSwgWyJ0ODQ5NiIsIDEuMF0sIFsidDg0OTciLCAxLjBdLCBbInQ4NDk4IiwgMS4wXSwgWyJ0ODQ5OSIsIDEuMF0sIFsidDg1MDAiLCAxLjBdLCBbInQ4NTAxIiwgMS4wXSwgWyJ0ODUwMiIsIDEuMF0sIFsidDg1MDMiLCAxLjBdLCBbInQ4NTA0IiwgMS4wXSwgWyJ0ODUwNSIsIDEuMF0sIFsidDg1MDYiLCAxLjBdLCBbInQ4NTA3IiwgMS4wXSwgWyJ0ODUwOCIsIDEuMF0sIFsidDg1MDkiLCAxLjBdLCBbInQ4NTEwIiwgMS4wXSwgWyJ0ODUxMSIsIDEuMF0sIFsidDg1MTIiLCAxLjBdLCBbInQ4NTEzIiwgMS4wXSwgWyJ0ODUxNCIsIDEuMF0sIFsidDg1MTUiLCAxLjBdLCBbInQ4NTE2IiwgMS4wXSwgWyJ0ODUxNyIsIDEuMF0sIFsidDg1MTgiLCAxLjBdLCBbInQ4NTE5IiwgMS4wXSwgWyJ0ODUyMCIsIDEuMF0sIFsidDg1MjEiLCAxLjBdLCBbInQ4NTIyIiwgMS4wXSwgWyJ0ODUyMyIsIDEuMF0sIFsidDg1MjQiLCAxLjBdLCBbInQ4NTI1IiwgMS4wXSwgWyJ0ODUyNiIsIDEuMF0sIFsidDg1MjciLCAxLjBdLCBbInQ4NTI4IiwgMS4wXSwgWyJ0ODUyOSIsIDEuMF0sIFsidDg1MzAiLCAxLjBdLCBbInQ4NTMxIiwgMS4wXSwgWyJ0ODUzMiIsIDEuMF0sIFsidDg1MzMiLCAxLjBdLCBbInQ4NTM0IiwgMS4wXSwgWyJ0ODUzNSIsIDEuMF0sIFsidDg1MzYiLCAxLjBdLCBbInQ4NTM3IiwgMS4wXSwgWyJ0ODUzOCIsIDEuMF0sIFsidDg1MzkiLCAxLjBdLCBbInQ4NTQwIiwgMS4wXSwgWyJ0ODU0MSIsIDEuMF0sIFsidDg1NDIiLCAxLjBdLCBbInQ4NTQzIiwgMS4wXSwgWyJ0ODU0NCIsIDEuMF0sIFsidDg1NDUiLCAxLjBdLCBbInQ4NTQ2IiwgMS4wXSwgWyJ0ODU0NyIsIDEuMF0sIFsidDg1NDgiLCAxLjBdLCBbInQ4NTQ5IiwgMS4wXSwgWyJ0ODU1MCIsIDEuMF0sIFsidDg1NTEiLCAxLjBdLCBbInQ4NTUyIiwgMS4wXSwgWyJ0ODU1MyIsIDEuMF0sIFsidDg1NTQiLCAxLjBdLCBbInQ4NTU1IiwgMS4wXSwgWyJ0ODU1NiIsIDEuMF0sIFsidDg1NTciLCAxLjBdLCBbInQ4NTU4IiwgMS4wXSwgWyJ0ODU1OSIsIDEuMF0sIFsidDg1NjAiLCAxLjBdLCBbInQ4NTYxIiwgMS4wXSwgWyJ0ODU2MiIsIDEuMF0sIFsidDg1NjMiLCAxLjBdLCBbInQ4NTY0IiwgMS4wXSwgWyJ0ODU2NSIsIDEuMF0sIFsidDg1NjYiLCAxLjBdLCBbInQ4NTY3IiwgMS4wXSwgWyJ0ODU2OCIsIDEuMF0sIFsidDg1NjkiLCAxLjBdLCBbInQ4NTcwIiwgMS4wXSwgWyJ0ODU3MSIsIDEuMF0sIFsidDg1NzIiLCAxLjBdLCBbInQ4NTczIiwgMS4wXSwgWyJ0ODU3NCIsIDEuMF0sIFsidDg1NzUiLCAxLjBdLCBbInQ4NTc2IiwgMS4wXSwgWyJ0ODU3NyIsIDEuMF0sIFsidDg1NzgiLCAxLjBdLCBbInQ4NTc5IiwgMS4wXSwgWyJ0ODU4MCIsIDEuMF0sIFsidDg1ODEiLCAxLjBdLCBbInQ4NTgyIiwgMS4wXSwgWyJ0ODU4MyIsIDEuMF0sIFsidDg1ODQiLCAxLjBdLCBbInQ4NTg1IiwgMS4wXSwgWyJ0ODU4NiIsIDEuMF0sIFsidDg1ODciLCAxLjBdLCBbInQ4NTg4IiwgMS4wXSwgWyJ0ODU4OSIsIDEuMF0sIFsidDg1OTAiLCAxLjBdLCBbInQ4NTkxIiwgMS4wXSwgWyJ0ODU5MiIsIDEuMF0sIFsidDg1OTMiLCAxLjBdLCBbInQ4NTk0IiwgMS4wXSwgWyJ0ODU5NSIsIDEuMF0sIFsidDg1OTYiLCAxLjBdLCBbInQ4NTk3IiwgMS4wXSwgWyJ0ODU5OCIsIDEuMF0sIFsidDg1OTkiLCAxLjBdLCBbInQ4NjAwIiwgMS4wXSwgWyJ0ODYwMSIsIDEuMF0sIFsidDg2MDIiLCAxLjBdLCBbInQ4NjAzIiwgMS4wXSwgWyJ0ODYwNCIsIDEuMF0sIFsidDg2MDUiLCAxLjBdLCBbInQ4NjA2IiwgMS4wXSwgWyJ0ODYwNyIsIDEuMF0sIFsidDg2MDgiLCAxLjBdLCBbInQ4NjA5IiwgMS4wXSwgWyJ0ODYxMCIsIDEuMF0sIFsidDg2MTEiLCAxLjBdLCBbInQ4NjEyIiwgMS4wXSwgWyJ0ODYxMyIsIDEuMF0sIFsidDg2MTQiLCAxLjBdLCBbInQ4NjE1IiwgMS4wXSwgWyJ0ODYxNiIsIDEuMF0sIFsidDg2MTciLCAxLjBdLCBbInQ4NjE4IiwgMS4wXSwgWyJ0ODYxOSIsIDEuMF0sIFsidDg2MjAiLCAxLjBdLCBbInQ4NjIxIiwgMS4wXSwgWyJ0ODYyMiIsIDEuMF0sIFsidDg2MjMiLCAxLjBdLCBbInQ4NjI0IiwgMS4wXSwgWyJ0ODYyNSIsIDEuMF0sIFsidDg2MjYiLCAxLjBdLCBbInQ4NjI3IiwgMS4wXSwgWyJ0ODYyOCIsIDEuMF0sIFsidDg2MjkiLCAxLjBdLCBbInQ4NjMwIiwgMS4wXSwgWyJ0ODYzMSIsIDEuMF0sIFsidDg2MzIiLCAxLjBdLCBbInQ4NjMzIiwgMS4wXSwgWyJ0ODYzNCIsIDEuMF0sIFsidDg2MzUiLCAxLjBdLCBbInQ4NjM2IiwgMS4wXSwgWyJ0ODYzNyIsIDEuMF0sIFsidDg2MzgiLCAxLjBdLCBbInQ4NjM5IiwgMS4wXSwgWyJ0ODY0MCIsIDEuMF0sIFsidDg2NDEiLCAxLjBdLCBbInQ4NjQyIiwgMS4wXSwgWyJ0ODY0MyIsIDEuMF0sIFsidDg2NDQiLCAxLjBdLCBbInQ4NjQ1IiwgMS4wXSwgWyJ0ODY0NiIsIDEuMF0sIFsidDg2NDciLCAxLjBdLCBbInQ4NjQ4IiwgMS4wXSwgWyJ0ODY0OSIsIDEuMF0sIFsidDg2NTAiLCAxLjBdLCBbInQ4NjUxIiwgMS4wXSwgWyJ0ODY1MiIsIDEuMF0sIFsidDg2NTMiLCAxLjBdLCBbInQ4NjU0IiwgMS4wXSwgWyJ0ODY1NSIsIDEuMF0sIFsidDg2NTYiLCAxLjBdLCBbInQ4NjU3IiwgMS4wXSwgWyJ0ODY1OCIsIDEuMF0sIFsidDg2NTkiLCAxLjBdLCBbInQ4NjYwIiwgMS4wXSwgWyJ0ODY2MSIsIDEuMF0sIFsidDg2NjIiLCAxLjBdLCBbInQ4NjYzIiwgMS4wXSwgWyJ0ODY2NCIsIDEuMF0sIFsidDg2NjUiLCAxLjBdLCBbInQ4NjY2IiwgMS4wXSwgWyJ0ODY2NyIsIDEuMF0sIFsidDg2NjgiLCAxLjBdLCBbInQ4NjY5IiwgMS4wXSwgWyJ0ODY3MCIsIDEuMF0sIFsidDg2NzEiLCAxLjBdLCBbInQ4NjcyIiwgMS4wXSwgWyJ0ODY3MyIsIDEuMF0sIFsidDg2NzQiLCAxLjBdLCBbInQ4Njc1IiwgMS4wXSwgWyJ0ODY3NiIsIDEuMF0sIFsidDg2NzciLCAxLjBdLCBbInQ4Njc4IiwgMS4wXSwgWyJ0ODY3OSIsIDEuMF0sIFsidDg2ODAiLCAxLjBdLCBbInQ4NjgxIiwgMS4wXSwgWyJ0ODY4MiIsIDEuMF0sIFsidDg2ODMiLCAxLjBdLCBbInQ4Njg0IiwgMS4wXSwgWyJ0ODY4NSIsIDEuMF0sIFsidDg2ODYiLCAxLjBdLCBbInQ4Njg3IiwgMS4wXSwgWyJ0ODY4OCIsIDEuMF0sIFsidDg2ODkiLCAxLjBdLCBbInQ4NjkwIiwgMS4wXSwgWyJ0ODY5MSIsIDEuMF0sIFsidDg2OTIiLCAxLjBdLCBbInQ4NjkzIiwgMS4wXSwgWyJ0ODY5NCIsIDEuMF0sIFsidDg2OTUiLCAxLjBdLCBbInQ4Njk2IiwgMS4wXSwgWyJ0ODY5NyIsIDEuMF0sIFsidDg2OTgiLCAxLjBdLCBbInQ4Njk5IiwgMS4wXSwgWyJ0ODcwMCIsIDEuMF0sIFsidDg3MDEiLCAxLjBdLCBbInQ4NzAyIiwgMS4wXSwgWyJ0ODcwMyIsIDEuMF0sIFsidDg3MDQiLCAxLjBdLCBbInQ4NzA1IiwgMS4wXSwgWyJ0ODcwNiIsIDEuMF0sIFsidDg3MDciLCAxLjBdLCBbInQ4NzA4IiwgMS4wXSwgWyJ0ODcwOSIsIDEuMF0sIFsidDg3MTAiLCAxLjBdLCBbInQ4NzExIiwgMS4wXSwgWyJ0ODcxMiIsIDEuMF0sIFsidDg3MTMiLCAxLjBdLCBbInQ4NzE0IiwgMS4wXSwgWyJ0ODcxNSIsIDEuMF0sIFsidDg3MTYiLCAxLjBdLCBbInQ4NzE3IiwgMS4wXSwgWyJ0ODcxOCIsIDEuMF0sIFsidDg3MTkiLCAxLjBdLCBbInQ4NzIwIiwgMS4wXSwgWyJ0ODcyMSIsIDEuMF0sIFsidDg3MjIiLCAxLjBdLCBbInQ4NzIzIiwgMS4wXSwgWyJ0ODcyNCIsIDEuMF0sIFsidDg3MjUiLCAxLjBdLCBbInQ4NzI2IiwgMS4wXSwgWyJ0ODcyNyIsIDEuMF0sIFsidDg3MjgiLCAxLjBdLCBbInQ4NzI5IiwgMS4wXSwgWyJ0ODczMCIsIDEuMF0sIFsidDg3MzEiLCAxLjBdLCBbInQ4NzMyIiwgMS4wXSwgWyJ0ODczMyIsIDEuMF0sIFsidDg3MzQiLCAxLjBdLCBbInQ4NzM1IiwgMS4wXSwgWyJ0ODczNiIsIDEuMF0sIFsidDg3MzciLCAxLjBdLCBbInQ4NzM4IiwgMS4wXSwgWyJ0ODczOSIsIDEuMF0sIFsidDg3NDAiLCAxLjBdLCBbInQ4NzQxIiwgMS4wXSwgWyJ0ODc0MiIsIDEuMF0sIFsidDg3NDMiLCAxLjBdLCBbInQ4NzQ0IiwgMS4wXSwgWyJ0ODc0NSIsIDEuMF0sIFsidDg3NDYiLCAxLjBdLCBbInQ4NzQ3IiwgMS4wXSwgWyJ0ODc0OCIsIDEuMF0sIFsidDg3NDkiLCAxLjBdLCBbInQ4NzUwIiwgMS4wXSwgWyJ0ODc1MSIsIDEuMF0sIFsidDg3NTIiLCAxLjBdLCBbInQ4NzUzIiwgMS4wXSwgWyJ0ODc1NCIsIDEuMF0sIFsidDg3NTUiLCAxLjBdLCBbInQ4NzU2IiwgMS4wXSwgWyJ0ODc1NyIsIDEuMF0sIFsidDg3NTgiLCAxLjBdLCBbInQ4NzU5IiwgMS4wXSwgWyJ0ODc2MCIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "1week", + "timeline", + [ + "InkyMDIwIg==", + "str" + ], + "1week_rolling" + ], + [ + "timeset", + "1week", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxNjguMF1dfQ==", + "map" + ], + "1week_rolling" + ], + [ + "timeset", + "2day", + "timeline", + [ + "InkyMDIwIg==", + "str" + ], + "2day" + ], + [ + "timeset", + "2day", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCA0OC4wXV19", + "map" + ], + "2day" + ], + [ + "unit", + "coal_plant", + "efficiency", + [ + "MC45", + "float" + ], + "system" + ], + [ + "unit", + "coal_plant", + "existing", + [ + "MjAwMA==", + "float" + ], + "system" + ], + [ + "unit", + "gas_plant", + "efficiency", + [ + "MC45", + "float" + ], + "system" + ], + [ + "unit", + "gas_plant", + "existing", + [ + "MTAwMA==", + "float" + ], + "system" + ], + [ + "unit", + "hydro_plant", + "efficiency", + [ + "Mi4w", + "float" + ], + "system" + ], + [ + "unit", + "hydro_plant", + "existing", + [ + "MjAwMA==", + "float" + ], + "system" + ], + [ + "unit", + "wind_plant", + "efficiency", + [ + "MC45", + "float" + ], + "system" + ], + [ + "unit", + "wind_plant", + "existing", + [ + "NDAwMA==", + "float" + ], + "system" + ], + [ + "unit__node__profile", + [ + "wind_plant", + "demand_node", + "wind" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "system" + ], + [ + "unit__outputNode", + [ + "hydro_plant", + "demand_node" + ], + "constraint_flow_coeff", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaHlkcm9fZGl2aWRlIiwgLTEuMF1dfQ==", + "map" + ], + "system" + ], + [ + "unit__outputNode", + [ + "hydro_plant", + "downriver" + ], + "constraint_flow_coeff", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaHlkcm9fZGl2aWRlIiwgMS4wXV19", + "map" + ], + "system" + ], + [ + "solve", + "2day_dispatch", + "stochastic_invest_method", + [ + "InJlY291cnNlIg==", + "str" + ], + "recourse_na" + ], + [ + "solve", + "2day_dispatch", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwZXJpb2QxIl19", + "array" + ], + "recourse_na" + ], + [ + "solve", + "2day_dispatch", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwZXJpb2QxIl19", + "array" + ], + "recourse_na" + ], + [ + "solve", + "2day_dispatch", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIDEuMF1dfQ==", + "map" + ], + "recourse_na" + ], + [ + "node", + "hydro_reservoir", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "recourse_na" + ], + [ + "node", + "hydro_reservoir", + "invest_cost", + [ + "MS4w", + "float" + ], + "recourse_na" + ], + [ + "node", + "hydro_reservoir", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "recourse_na" + ], + [ + "node", + "hydro_reservoir", + "lifetime", + [ + "MS4w", + "float" + ], + "recourse_na" + ], + [ + "node", + "hydro_reservoir", + "invest_max_total", + [ + "MTAwMDAwLjA=", + "float" + ], + "recourse_na" + ] + ], + "alternatives": [ + [ + "1week_rolling", + "" + ], + [ + "2day", + "" + ], + [ + "Base", + "Base alternative" + ], + [ + "base", + "" + ], + [ + "init", + "Basic model, solve and time settings" + ], + [ + "no_storage_override", + "Phase 3d: drain hydro_reservoir storage by setting node_type='commodity'." + ], + [ + "system", + "" + ], + [ + "recourse_na", + "Recourse invest on the stochastic storage node" + ] + ], + "scenarios": [ + [ + "1_week_rolling_wind", + false, + "" + ], + [ + "2_day_stochastic_dispatch", + false, + "" + ], + [ + "2_day_stochastic_dispatch_no_storage", + false, + null + ], + [ + "recourse_na", + false, + "Variant B \u2014 cross-scenario coupling (stochastic storage node)" + ] + ], + "scenario_alternatives": [ + [ + "1_week_rolling_wind", + "init", + "base" + ], + [ + "1_week_rolling_wind", + "base", + "system" + ], + [ + "1_week_rolling_wind", + "system", + "1week_rolling" + ], + [ + "1_week_rolling_wind", + "1week_rolling", + null + ], + [ + "2_day_stochastic_dispatch", + "base", + "init" + ], + [ + "2_day_stochastic_dispatch", + "init", + "system" + ], + [ + "2_day_stochastic_dispatch", + "system", + "2day" + ], + [ + "2_day_stochastic_dispatch", + "2day", + null + ], + [ + "2_day_stochastic_dispatch_no_storage", + "base", + "init" + ], + [ + "2_day_stochastic_dispatch_no_storage", + "init", + "system" + ], + [ + "2_day_stochastic_dispatch_no_storage", + "system", + "2day" + ], + [ + "2_day_stochastic_dispatch_no_storage", + "2day", + "no_storage_override" + ], + [ + "2_day_stochastic_dispatch_no_storage", + "no_storage_override", + null + ], + [ + "recourse_na", + "base", + "init" + ], + [ + "recourse_na", + "init", + "system" + ], + [ + "recourse_na", + "system", + "2day" + ], + [ + "recourse_na", + "2day", + "recourse_na" + ], + [ + "recourse_na", + "recourse_na", + null + ] + ] +} diff --git a/tests/test_recourse_cost_reconciliation.py b/tests/test_recourse_cost_reconciliation.py new file mode 100644 index 000000000..7028996cf --- /dev/null +++ b/tests/test_recourse_cost_reconciliation.py @@ -0,0 +1,107 @@ +"""Slice D (I-a, §11.3 / §15.2 D8) — post-hoc invest-cost reconciliation. + +Runs the ``recourse`` scenario of ``stoch_two_period_invest`` through the +FULL output pipeline (``write_outputs``) and asserts that the reported +investment cost reconciles to the LP objective's REALIZED invest +contribution — i.e. ``calc_costs`` probability-weights the committed +invest by ``pd_branch_weight`` under recourse (choice I-a), so the cost +breakdown sums back to the objective rather than over-reporting by +``1/w_realized``. + +Hand values (design §15.1): committed realized invest is 60 MW at p2035 +(annuity 2100) and 30 MW at p2040 (annuity 1050), realized branch weight +0.25: + + invest cost = 0.25·(60·2100 + 30·1050) = 39 375 (0.039375 M CUR) + +The UNWEIGHTED (I-b) recompute would report 60·2100 + 30·1050 = 157 500 +(0.1575 M) — 1/0.25 = 4× larger — so the assertion discriminates the +weighting. The full-expectation objective (0.196875 M) additionally +carries the non-realized low-branch contribution, which the committed +cost path correctly omits (§11.1). +""" +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import pytest + +TEST_DIR = Path(__file__).parent +REPO_ROOT = TEST_DIR.parent +OUTPUT_CONFIG = str(REPO_ROOT / "templates" / "default_plots.yaml") + +if str(TEST_DIR) not in sys.path: + sys.path.insert(0, str(TEST_DIR)) + +from flextool.engine_polars import run_chain_from_db # noqa: E402 +from flextool.engine_polars._flex_data_provider import ( # noqa: E402 + FlexDataProvider, +) +from flextool.process_outputs.write_outputs import write_outputs # noqa: E402 + + +def _summary_row(csv_dir: Path, label: str) -> float: + """Return the numeric value on the ``summary_solve.csv`` row whose + first cell equals ``label`` (the file is an irregular, multi-shape + CSV, so scan row-by-row).""" + with open(csv_dir / "summary_solve.csv", newline="") as f: + for row in csv.reader(f): + if row and row[0] == label: + return float(row[1]) + raise AssertionError(f"summary_solve.csv has no {label!r} row") + + +@pytest.mark.slow +def test_recourse_invest_cost_reconciles_to_objective( + stoch_two_period_invest_db_url: str, + test_solver_config_dir: Path, + tmp_path_factory: pytest.TempPathFactory, +) -> None: + import os + + wf = tmp_path_factory.mktemp("recourse_cost") + os.chdir(wf) + steps = run_chain_from_db( + stoch_two_period_invest_db_url, + "recourse", + work_folder=wf, + solver_config_dir=test_solver_config_dir, + csv_dump=True, + keep_solutions=True, + ) + last = next(reversed(steps.values())) + assert last.solution is not None and last.solution.optimal + provider = getattr(last, "flex_data_provider", None) + if provider is not None: + provider.snapshot_processed_inputs(wf) + + write_outputs( + scenario_name="recourse", + output_location=str(wf), + subdir="recourse", + output_config_path=OUTPUT_CONFIG, + write_methods=["csv"], + fallback_output_location=str(wf), + raw_output_dir=str(wf / "output_raw"), + solution=last.solution, + solve_name=last.solve_name, + solve_steps=[ + (s.solve_name, s.flex_data, s.effective_solution) + for s in steps.values() + ], + flex_data_provider=FlexDataProvider(), + ) + csv_dir = wf / "output_csv" / "recourse" + + objective = _summary_row(csv_dir, "stoch_2p_inv") + invest_realized = _summary_row( + csv_dir, "Investment costs for realized periods (M CUR)") + + # I-a: the reported invest cost is the probability-weighted realized + # contribution 0.25·(60·2100 + 30·1050) = 0.039375 M — NOT the + # unweighted 0.1575 M (which would not reconcile to the objective). + assert invest_realized == pytest.approx(0.039375, rel=1e-6) + # The full-expectation objective carries the non-realized branches too. + assert objective == pytest.approx(0.196875, rel=1e-6) From edb158fa998359bb21070b6fabd54d72bd7cc148 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 09:53:47 +0300 Subject: [PATCH 17/87] docs(stochastic): D9 per-scenario invest recipe + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - how_to.md: replace the "only one period investment stochastic models are supported" line with the per-scenario (wait-and-see) recourse recipe — what the objective means (expected value of per-scenario plans, EVPI-style; not a hedged plan), the coupling change vs the default, the α-1 cross-comparability caveat, and the Benders exclusion (design sliceD §16). - reference.md: document solve.stochastic_invest_method (none/recourse), the output_horizon interaction, and the Benders exclusion. - CHANGELOG: Unreleased draft entry (public wording, no internal model names; schema change note). No version bump — release is the owner's decision. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- CHANGELOG.md | 15 +++++++++++++++ docs/how_to.md | 20 +++++++++++++++++++- docs/reference.md | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061efcd4b..58ee78140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## Unreleased + +Schema change (adds `solve.stochastic_invest_method`; migrated automatically). + +- **Per-scenario stochastic investment** (opt-in + `solve.stochastic_invest_method = recourse`): each stochastic branch can now + make its own investment decisions at and after the branching period, with + probability-weighted investment costs and per-scenario investment limits — a + wait-and-see analysis giving per-scenario optimal plans and their expected + cost. Committed results report the realized scenario; non-realized branch + investments are available under the horizon-output debug flag + (`model.output_horizon`). Shared (hedged) pre-branch investment is a planned + follow-up. Default behaviour (`none`) is unchanged, and the option is + rejected in combination with Benders decomposition. + ## Release 4.0.4 (21.9.2026) — VRE curtailment output & solver option fixes Patch release. No schema changes. This release also ships the 4.0.3 changes diff --git a/docs/how_to.md b/docs/how_to.md index bb1737c35..6a2fad320 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1123,7 +1123,25 @@ Stochastics are used to represent the uncertainty of the future in the decision ![Stochastic system](./img/concept/non-anticipatory.png) -For the stochastics to have an effect on the results, the system needs parameters that change between the stochastic branches. These could be e.g. wind power generation or fuel prices. The model will then have separate variables in every branch for all the decision the model can take (e.g. invesment, storage state, online, flow). As a consequence, the realization phase will also be dependent on the things that happen in the stochastic branches - weighted by the probablity given to each branch. Only one period investment stochastic models are supported currently. +For the stochastics to have an effect on the results, the system needs parameters that change between the stochastic branches. These could be e.g. wind power generation or fuel prices. The model will then have separate variables in every branch for all the decision the model can take (e.g. invesment, storage state, online, flow). As a consequence, the realization phase will also be dependent on the things that happen in the stochastic branches - weighted by the probablity given to each branch. + +### Per-scenario (wait-and-see) stochastic investment + +By default (`solve.stochastic_invest_method = none`) a stochastic model makes a **single** investment plan that every branch's dispatch must share — one plan serves all futures. Set + +``` +solve.stochastic_invest_method = recourse +``` + +to switch to **per-scenario (wait-and-see) investment**: every branch (the realized branch included) makes its *own* investment decisions at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are visible only when `model.output_horizon` is enabled (they are debug output — the committed results always report the realized scenario only). + +Read the results with these three points in mind: + +1. **What the objective means.** It is the *expected value of per-scenario optimal plans* — each scenario's investments are chosen knowing that scenario's data (a wait-and-see analysis / EVPI-style bound). It is **not** a single hedged plan. Use it for scenario screening and per-scenario capacity ranges. A hedged (here-and-now) plan that commits shared capacity *before* the uncertainty reveals is a planned follow-up, not what `recourse` produces today. +2. **The coupling change vs. the default.** Under `none` the single realized investment is visible to every branch's dispatch. Switching to `recourse` replaces that shared plan with independent per-branch plans, so the objective typically **drops** on the same data (each branch now gets tailor-made capacity). Comparing objectives across the flag compares two different decision models, not two answers to the same question. +3. **Cross-comparability caveat.** Turning on `recourse` also corrects the realized branch's own investment-annuity windows (the branch-period year/factor resolution rides this flag), so even the realized branch's investment costs are **not** directly A/B-comparable across the flag. Do not diff realized results across the flag and attribute the whole delta to "branch investing". + +`recourse` is incompatible with Benders decomposition (the solver rejects the combination). In this example, we show two ways to use stochastics: Single solve, rolling horizon. They all share the same test system that includes: diff --git a/docs/reference.md b/docs/reference.md index f7ca5f73b..9f34e6896 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -80,6 +80,7 @@ Timesets pick one or more sections from the `timeline` to form a `timeset`. Each - Stochastic parameters: - *stochastic_branches*: 4D-map to set up the stochastic branches, their weights and to choose which of them are realized. See 'How to use stochastics' for more information. + - *stochastic_invest_method*: `none` (default) or `recourse`. `none` makes a single shared investment plan across all branches. `recourse` gives per-scenario (wait-and-see) investment: every branch invests on its own data at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are output only under `model.output_horizon`. Incompatible with Benders decomposition. See 'How to use stochastics' for the interpretation caveats. - For commercial solvers: From 28e35b0f826dec3805fd4e57b0f16995991ab9a0 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 10:22:19 +0300 Subject: [PATCH 18/87] test(stochastic): mark recourse cost reconciliation as solver The end-to-end cost-reconciliation test invokes a real solve; add the ``solver`` marker so it deselects under ``-m "not solver"`` like the other recourse solver tests (it already carried ``slow``). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- tests/test_recourse_cost_reconciliation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_recourse_cost_reconciliation.py b/tests/test_recourse_cost_reconciliation.py index 7028996cf..61333b808 100644 --- a/tests/test_recourse_cost_reconciliation.py +++ b/tests/test_recourse_cost_reconciliation.py @@ -54,6 +54,7 @@ def _summary_row(csv_dir: Path, label: str) -> float: @pytest.mark.slow +@pytest.mark.solver def test_recourse_invest_cost_reconciles_to_objective( stoch_two_period_invest_db_url: str, test_solver_config_dir: Path, From f0ea27d512084c177840515a6ae6dcbca32a39c3 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 10:51:43 +0300 Subject: [PATCH 19/87] fix(stochastic): read recourse flag via provider, not disk (cascade invariant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D0 introduced _recourse_invest_active with a provider-first / workdir-disk fallback read of solve_data/stochastic_invest_method.csv. The pl.read_csv disk arm tripped test_meta_provider_invariants::test_no_disk_csv_reads_in_cascade (Rule 1) — the same seed-funnel bug class fixed for the Slice B obligation checker in 91db38b4. Make the reader Provider-only (_provider_has_key -> _provider_read), matching every other reader in this module (_build_recourse_anchor_pairs et al.). The flag CSV reaches the cascade exclusively through the Provider on all production paths: cascade entry points thread an explicit Provider, and the snapshot-reload path (load_flextool off a dumped workdir) seeds the Provider from solve_data/ via seed_provider_from_dir before the cascade runs, so it carries the solve_data/stochastic_invest_method key by construction. The recourse gate is unchanged (returns True under flag='recourse' on both the normal and reload paths). Update test_recourse_plumbing to seed a Provider from the workdir (mirroring the cascade) instead of relying on the removed disk arm; add a no-Provider case asserting the byte-parity-safe default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_params.py | 36 ++++++++++++------- tests/engine_polars/test_recourse_plumbing.py | 33 ++++++++++++++--- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 62c9efdb2..c4d4aadae 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -124,18 +124,30 @@ def _recourse_invest_active( """True iff ``solve_data/stochastic_invest_method.csv`` == ``'recourse'``. Slice D's single gate. False when the CSV is absent (deterministic / - flag-off / pre-Slice-D dumps) — the byte-parity-safe default. Reads - provider-first (canonical key), then a workdir-disk fallback so a - dumped/reloaded solve still resolves the flag. - """ - path = Path("solve_data") / "stochastic_invest_method.csv" - df: "pl.DataFrame | None" = None - if _provider_has_key(provider, path): - df = _provider_read(provider, path) - elif workdir is not None: - disk = Path(workdir) / "solve_data" / "stochastic_invest_method.csv" - if disk.exists(): - df = pl.read_csv(disk) + flag-off / pre-Slice-D dumps) — the byte-parity-safe default. + + Provider-only read (canonical key), matching every other reader in + this module (``_build_recourse_anchor_pairs`` et al.) and the post- + Step-2 cascade invariant (``test_meta_provider_invariants``): the + flag CSV reaches the cascade exclusively through the Provider, never + from disk. On the snapshot-reload path (``load_flextool`` off a + dumped workdir) the Provider is seeded from ``solve_data/`` before + the cascade runs (``input.load_flextool`` → + ``seed_provider_from_dir``), so it carries this key by construction; + cascade entry points always thread an explicit Provider. Tests that + exercise the flag seed the Provider from the workdir the same way. + ``workdir`` is retained in the signature only to key the canonical + Provider path (``_provider_key`` ignores the prefix, so the key is + stable whether or not a workdir is supplied). + """ + path = ( + Path(workdir) / "solve_data" / "stochastic_invest_method.csv" + if workdir is not None + else Path("solve_data") / "stochastic_invest_method.csv" + ) + if not _provider_has_key(provider, path): + return False + df = _provider_read(provider, path) if df is None or df.height == 0 or "method" not in df.columns: return False val = df["method"].cast(pl.Utf8, strict=False).to_list()[0] diff --git a/tests/engine_polars/test_recourse_plumbing.py b/tests/engine_polars/test_recourse_plumbing.py index e554837c4..51467f1b1 100644 --- a/tests/engine_polars/test_recourse_plumbing.py +++ b/tests/engine_polars/test_recourse_plumbing.py @@ -27,23 +27,48 @@ def _write(workdir, text: str) -> None: (p / "stochastic_invest_method.csv").write_text(text) +def _seeded_provider(workdir): + """Provider seeded from ``workdir/solve_data`` — mirrors the cascade, + which reads the flag exclusively from the Provider (never disk). On + the real run/reload paths ``load_flextool`` seeds the Provider the + same way before the reader runs. + """ + from flextool.engine_polars._flex_data_provider import FlexDataProvider + from flextool.engine_polars._input_source import seed_provider_from_dir + + prov = FlexDataProvider() + sd = workdir / "solve_data" + if sd.exists(): + seed_provider_from_dir(prov, sd, "solve_data") + return prov + + # --------------------------------------------------------------------------- -# Reader — workdir-disk fallback + absent-default +# Reader — provider-only read + absent-default # --------------------------------------------------------------------------- def test_recourse_active_true(tmp_path): _write(tmp_path, "method\nrecourse\n") - assert _recourse_invest_active(tmp_path) is True + assert _recourse_invest_active( + tmp_path, provider=_seeded_provider(tmp_path)) is True def test_recourse_active_none_flag_false(tmp_path): _write(tmp_path, "method\nnone\n") - assert _recourse_invest_active(tmp_path) is False + assert _recourse_invest_active( + tmp_path, provider=_seeded_provider(tmp_path)) is False def test_recourse_active_absent_csv_false(tmp_path): - # No CSV emitted — the byte-parity-safe default. + # No CSV emitted — the byte-parity-safe default (Provider carries no key). + assert _recourse_invest_active( + tmp_path, provider=_seeded_provider(tmp_path)) is False + + +def test_recourse_active_no_provider_false(tmp_path): + # No Provider → no key → byte-parity-safe default (never touches disk). + _write(tmp_path, "method\nrecourse\n") assert _recourse_invest_active(tmp_path) is False From c691612a2d64f3b5a7491e3564a71a0992bccdf1 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 10:57:46 +0300 Subject: [PATCH 20/87] fix(migration): create solve_advanced group if absent in v70 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v70 migration (_migrate_v70_stochastic_invest_method) attached solve.stochastic_invest_method to the solve_advanced parameter_group only "when that group exists", but probed existence with the raising db.item(db.mapped_table("parameter_group"), name="solve_advanced"). In the current spinedb-api, db.item RAISES SpineDBAPIError when the item is absent (it does not return None), so the `is not None` guard never helped — it crashed before returning. solve_advanced is created by the v44 migration (recoloured in v45), so real DBs migrated from < v44 and the shipped template always carry it. But a DB built from scratch at a version above v44 never ran the v44 step and has no parameter_groups at all — e.g. the minimal v64 fixtures that tests/test_v65_migration.py and tests/test_v66_migration.py seed and then migrate forward through v70. Those two tests crashed on the v70 step (regression vs main, where they pass). Fix: use the non-raising db.get_item guard and get-or-create the group — create solve_advanced (mirroring v44's priority 87 and the post-v45 colour b56f6f) only when the DB lacks it, then always attach the param. This keeps the param in its canonical schema home and satisfies the v69 invariant that every parameter belongs to a group; it is a clean forward add, not crash-tolerance. The v70 fixture (lh2_three_region) already has the group, so the migrate-from-v69 path is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/update_flextool/db_migration.py | 33 +++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 53c008228..6b9386d23 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -3790,7 +3790,7 @@ def _migrate_v70_stochastic_invest_method(db) -> None: Slice C ships only the schema + the validation guards: flag-on is HARD-REJECTED until Slice D lands (branch periods on the invest axis). Bound to a new 'stochastic_invest_methods' value list; - grouped under 'solve_advanced' when that group exists. + grouped under 'solve_advanced' (created if the DB lacks it). """ add_value_list_manual(db, [ ["stochastic_invest_methods", "none"], @@ -3818,17 +3818,30 @@ def _migrate_v70_stochastic_invest_method(db) -> None: "nested-tree modes." ), ) - has_solve_advanced = ( - db.item(db.mapped_table("parameter_group"), name="solve_advanced") - is not None - ) - if has_solve_advanced: + # Attach to the 'solve_advanced' parameter group, creating it if the + # DB lacks it. Every parameter must belong to a parameter_group so it + # is not dropped from group-filtered tabular exports (v69 invariant, + # guarded by tests/test_parameter_group_coverage.py), and solve_advanced + # is this param's canonical home in the schema. Real DBs always have it + # (created in v44, recoloured in v45), but a DB built from scratch at a + # version above v44 — e.g. the minimal fixtures the migration tests seed + # — never ran the v44 step and so carries no parameter_groups. Use the + # non-raising get_item (db.item raises SpineDBAPIError when absent) and + # get-or-create; the create only fires on such group-less DBs and mirrors + # v44's colour/priority for solve_advanced (post-v45 colour b56f6f). + if not db.get_item("parameter_group", name="solve_advanced"): db.add_update_item( - "parameter_definition", - entity_class_name="solve", - name="stochastic_invest_method", - parameter_group_name="solve_advanced", + "parameter_group", + name="solve_advanced", + color="b56f6f", + priority=87, ) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="stochastic_invest_method", + parameter_group_name="solve_advanced", + ) _commit_step(db, "v70: added solve.stochastic_invest_method " From 4e86d64a938ce804a376a2c9ee6b824a45f6f6d7 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 11:41:24 +0300 Subject: [PATCH 21/87] fix(stochastic): F1 defensive per-solve reset of recourse globals The three recourse process-globals (_RECOURSE_ANCHOR_PAIRS, _RECOURSE_WALK_PROVIDER, _RECOURSE_WALK_WORKDIR) were reset only inside the three flag-aware Layer-4 boundaries. Add _reset_recourse_scope() called once at the top of _apply_db_overrides (the single per-solve cascade entry, before pass 1a) so a future per-period producer wired into an earlier cascade step cannot inherit a stale global from a prior flag-on solve in a chained flag-on->flag-off multi-solve. The boundaries still overwrite unconditionally; this is belt-and-suspenders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_params.py | 20 ++++++++++++++++++++ flextool/engine_polars/input.py | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index c4d4aadae..ac19db7f2 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -271,6 +271,26 @@ def _build_recourse_anchor_pairs( return out if out.height > 0 else None +def _reset_recourse_scope() -> None: + """Clear all three recourse process-globals to ``None``. + + Defensive per-solve reset: called once at the top of the cascade + entry (:func:`.input._apply_db_overrides`) BEFORE any pass runs, so a + future per-period producer wired into an EARLIER cascade step cannot + inherit a stale anchor-pairs holder / walker Provider from a prior + flag-on solve in a chained flag-on→flag-off multi-solve. The three + flag-aware Layer-4 boundaries still overwrite these unconditionally + (to ``None`` when flag-off), so this reset is belt-and-suspenders; + the invariant it guards is "no producer sees a global from a + different solve". + """ + global _RECOURSE_ANCHOR_PAIRS, _RECOURSE_WALK_PROVIDER, \ + _RECOURSE_WALK_WORKDIR + _RECOURSE_ANCHOR_PAIRS = None + _RECOURSE_WALK_PROVIDER = None + _RECOURSE_WALK_WORKDIR = None + + def _enter_recourse_anchor_scope( workdir: Path | None, *, diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 899e96722..6a078c49a 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -4848,6 +4848,13 @@ def _apply_db_overrides(flex_data: "FlexData", db_reader: "InputSource", from flextool.engine_polars import _projection_params as _pp from flextool.engine_polars import _derived_params as _drv + # F1 — defensive per-solve reset of the recourse process-globals BEFORE + # any cascade pass runs, so no producer can inherit a stale anchor-pairs + # holder / walker Provider from a prior solve in a chained multi-solve. + # (The flag-aware Layer-4 boundaries still overwrite them; this just + # guarantees the invariant for any future earlier-step per-period producer.) + _drv._reset_recourse_scope() + from flextool.engine_polars._orchestration import get_phase_recorder _rec = get_phase_recorder() _logger = logging.getLogger("flextool.engine_polars.input") From 3455dce5828ba758d8c03ba88a076b8c0bf5e065 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 11:41:31 +0300 Subject: [PATCH 22/87] fix(stochastic): F2 hard-raise on nulled token in _anchor_expand_explicit The final cast(d_dtype, strict=False) could silently null an out-of-vocab token, contradicting the docstring's claim that the round-trip "cannot silently null a token." Mirror the sibling _derived_branch.d_leaf_lf: on the non-Utf8 (Enum) path, collect and hard-raise on any nulled token instead of dropping it. Cannot fire in valid runs -- anchor tokens arrive already typed as d_dtype (lossless Utf8->Enum round-trip) and branch (br) tokens are covered by the 4.0.4 vocabulary splice. Utf8-d callers (both production call sites) skip the collect entirely, so no hot-path laziness/perf regression. Docstring corrected to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_params.py | 27 ++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index ac19db7f2..255e00211 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -326,7 +326,12 @@ def _anchor_expand_explicit(explicit_lf: pl.LazyFrame) -> pl.LazyFrame: row keeps it (branch-native wins, via order-preserving dedupe). Holder ``None`` (flag-off / deterministic) → returns the frame unchanged (byte-parity). Operates in Utf8 on ``d`` then restores the - original dtype so an Enum-vocab mismatch cannot silently null a token. + original dtype. The Utf8→Enum round-trip is lossless for anchor rows + by construction (their ``d`` arrived already typed as ``d_dtype``, so + each token is in-vocab), and every inherited branch (``br``) token is + covered by the 4.0.4 vocabulary splice; a nulled token therefore + signals a wiring bug and is hard-raised (mirroring + :func:`._derived_branch.d_leaf_lf`) rather than silently dropped. """ pairs = _RECOURSE_ANCHOR_PAIRS if pairs is None or pairs.height == 0: @@ -340,8 +345,24 @@ def _anchor_expand_explicit(explicit_lf: pl.LazyFrame) -> pl.LazyFrame: .select(base.collect_schema().names())) combined = pl.concat([base, inherited], how="vertical_relaxed") out = combined.unique(subset=["e", "d"], keep="first", maintain_order=True) - if d_dtype is not None: - out = out.with_columns(pl.col("d").cast(d_dtype, strict=False)) + if d_dtype is not None and d_dtype != pl.Utf8: + # Restore the original (non-Utf8, i.e. Enum) ``d`` dtype. A + # ``strict=False`` cast would SILENTLY null any token absent from + # the live ``d``-axis vocabulary; hard-raise instead so a splice + # gap surfaces loudly (never fires in valid runs — see docstring). + out_df = out.collect() + casted = out_df.with_columns(pl.col("d").cast(d_dtype, strict=False)) + if casted["d"].null_count(): + bad = sorted({v for v, c in zip(out_df["d"].to_list(), + casted["d"].to_list()) + if c is None}) + raise ValueError( + f"_anchor_expand_explicit: axis-enum cast nulled period " + f"token(s) {bad} — the token is missing from the live " + "d-axis vocabulary (wiring bug; the 4.0.4 vocabulary splice " + "includes every continuation branch-period token)." + ) + return casted.lazy() return out From 289c462c1ebcb8a1662402de1709e241d454da45 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 13:21:16 +0300 Subject: [PATCH 23/87] feat(stochastic): E0 mid-horizon fan-out + storage linkage Enable create_stochastic_periods to trigger the branch fan at a period k>1 (mid-horizon reveal) while keeping pre-reveal periods as a single real-named trunk (Option B, non-anticipativity by construction). - _stochastic.py: replace the 'realized row at the solve first step' validation with a well-formedness check (period-boundary reveal + exactly-one-realized per branching period), admitting a boundary mid-horizon reveal byte-identically for fan-at-first-step. - _timeline.py make_step_jump + _derived_params.py dtttdt_from_source: a branch copy with no earlier same-time-branch period now links its first step to the nearest anchor STRICTLY BEFORE the copy's own anchor (shared pre-reveal trunk end-state) instead of self-cycling. First- period fan self-cycles unchanged (empty anchor range) -> byte-parity. Byte-parity gate green (recourse 196875, storage *_na goldens unmoved). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_params.py | 19 +++++ flextool/engine_polars/_stochastic.py | 71 +++++++++++++------ flextool/engine_polars/_timeline.py | 86 +++++++++++++++++++---- 3 files changed, 138 insertions(+), 38 deletions(-) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 255e00211..38b049a45 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -7068,6 +7068,25 @@ def dtttdt_from_source(source: "InputSource", if time_branch_of.get(period_order[qi]) == tb: wrap_period = period_order[qi] break + if wrap_period == period: + # No earlier same-time-branch period exists. MID-HORIZON + # reveal: this branch copy's own anchor period is not the + # first period, so link its first step to the nearest + # anchor STRICTLY BEFORE the copy's own anchor — the copy + # inherits the shared pre-reveal trunk end-state (two-stage + # storage-state non-anticipativity, Slice E design §5.2). + # FIRST-PERIOD fan: the copy's anchor IS the first period + # → empty range → wrap_period stays == period (self-wrap, + # byte-parity with the pre-Slice-E behaviour). This mirror + # of ``make_step_jump`` is pinned by the Step-0.5 parity + # guard. + p_anchor = branch_anchor.get(period) + if p_anchor is not None and p_anchor in period_order: + pos_anchor = period_order.index(p_anchor) + for qi in range(pos_anchor - 1, -1, -1): + if period_order[qi] in anchors_with_self: + wrap_period = period_order[qi] + break else: if pi == 0: wrap_period = period_order[-1] diff --git a/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index 09140012a..0814287bf 100644 --- a/flextool/engine_polars/_stochastic.py +++ b/flextool/engine_polars/_stochastic.py @@ -440,30 +440,55 @@ def create_stochastic_periods( active_branches: list[str] = [] branch_start_time_lists[solve] = None - # First step of the solve (used for validation below). - first_step: tuple[str, str] | None = None - for period, active_time in active_time_list.items(): - first_step = (period, active_time[0].timestep) - break - - # Validate: when stochastic_branches has rows for this - # complete-solve, at least one row's start_step must match - # the solve's first step AND be marked realized. - found_start = False + # Validate the stochastic_branches rows as a WELL-FORMED + # (possibly mid-horizon) reveal (Slice E design §3.3). Two + # checks replace the old "realized row at the solve's first + # step" guard while preserving fan-at-first-step behaviour + # byte-for-byte: + # (1) every row must start at the FIRST step of the period + # it names — investment is period-granular, so a reveal + # is a PERIOD BOUNDARY (§3.1); a mid-period start_step + # is ill-defined. + # (2) each branching period must carry EXACTLY ONE + # realized:yes branch. For fan-at-first-step this + # reproduces the old found_start guarantee (one realized + # row at the solve start); it additionally rejects a + # malformed reveal with zero or two realized rows in a + # period. + # Because pre-reveal periods author NO stochastic_branches + # rows, the trigger loop below simply keeps them as real-named + # trunk periods (Option B — non-anticipativity by + # construction); no trigger-body change is needed for + # mid-horizon. + first_step_of = { + p: at[0].timestep for p, at in active_time_list.items() + } for row in info: - if first_step is not None and first_step[1] == row[2] and "yes" == row[3]: - found_start = True - if found_start is False and len(info) != 0: - message = ( - "A realized start time of the solve cannot be found from " - "the stochastic_branches parameter. " - "Check that stochastic_branches has a realized : yes, " - "branch for the start of the solve " - "and that the possible rolling_jump matches with the " - "branch starts" - ) - self.logger.error(message) - raise FlexToolConfigError(message) + if row[0] not in first_step_of or row[2] != first_step_of[row[0]]: + message = ( + "A stochastic branch must start at the FIRST step of " + "its branching period (period-boundary reveal). " + f"Branch {row[1]!r} of period {row[0]!r} declares " + f"start_step {row[2]!r}, which is not that period's " + "first step. Check stochastic_branches." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + by_period: defaultdict[str, list] = defaultdict(list) + for row in info: + by_period[row[0]].append(row) + for period_k, rows_k in by_period.items(): + n_real = sum(1 for r in rows_k if r[3] == "yes") + if n_real != 1: + message = ( + f"stochastic branching period {period_k!r} must have " + f"exactly one realized:yes branch; found {n_real}. " + "Check stochastic_branches (one realized branch per " + "branching period; start_step at the period's first " + "step)." + ) + self.logger.error(message) + raise FlexToolConfigError(message) # Walk periods — at the first branch trigger, fan out into # branches; subsequent periods continue the same branches. diff --git a/flextool/engine_polars/_timeline.py b/flextool/engine_polars/_timeline.py index fc4b9395c..092b6332a 100644 --- a/flextool/engine_polars/_timeline.py +++ b/flextool/engine_polars/_timeline.py @@ -1236,21 +1236,77 @@ def make_step_jump( ), ) else: - jump = ( - active_time[j].index - active_time[-1].index - ) - step_lengths.insert( - period_start_pos, - ( - period, - step.timestep, - active_time[j - 1].timestep, - active_time[block_last].timestep, - period, - active_time_list[period][-1].timestep, - jump, - ), - ) + # No earlier same-time-branch period exists. Two + # sub-cases (Slice E design §5.2): + # * MID-HORIZON reveal — this branch copy's own + # anchor period is NOT the first period, so a + # shared pre-reveal trunk period precedes it. + # The copy must inherit the shared end-of-trunk + # storage state, so link its first step to the + # nearest anchor STRICTLY BEFORE the copy's own + # anchor period (two-stage storage-state + # non-anticipativity). + # * FIRST-PERIOD fan — the copy's anchor IS the + # first period; no earlier anchor exists, so it + # self-cycles on its own last step (byte-parity + # with the pre-Slice-E behaviour). + # The search is anchored on the copy's OWN anchor + # period (via the (a, b) period__branch rows), NOT + # the copy's list index — an index-based search + # would find the copy's own realized sibling in the + # same calendar period (mid-horizon) or the first + # anchor at index 0 (first-period fan, breaking + # byte-parity). + prev_anchor = None + p_anchor = None + for a, b in period__branch: + if b == period and a != period: + p_anchor = a + break + if p_anchor is not None and p_anchor in period_names: + pos_anchor = period_names.index(p_anchor) + for kk in range(pos_anchor - 1, -1, -1): + if ( + period_names[kk], + period_names[kk], + ) in period__branch: + prev_anchor = period_names[kk] + break + if prev_anchor is not None: + jump = ( + active_time[j].index + - active_time_list[prev_anchor][-1].index + ) + step_lengths.insert( + period_start_pos, + ( + period, + step.timestep, + active_time[j - 1].timestep, + active_time[block_last].timestep, + prev_anchor, + active_time_list[prev_anchor][ + -1 + ].timestep, + jump, + ), + ) + else: + jump = ( + active_time[j].index - active_time[-1].index + ) + step_lengths.insert( + period_start_pos, + ( + period, + step.timestep, + active_time[j - 1].timestep, + active_time[block_last].timestep, + period, + active_time_list[period][-1].timestep, + jump, + ), + ) else: jump = ( active_time[j].index From daa5d93a89f1644d2fac999df01ebf6f591bd157 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 13:21:31 +0300 Subject: [PATCH 24/87] feat(stochastic): E1-E3 shared-trunk lineage, per-path caps, annuity dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option B correctness for the shared pre-reveal trunk: - _lineage_leaves now returns (label, members) pairs (F4) so callers carry the authoritative leaf label instead of a brittle zip-alignment. - d_leaf: partition -> RELATION. A shared pre-reveal anchor belongs to EVERY branch leaf's path, so the per-path total cap counts it into each leaf's total (byte-parity for fan-at-first/deterministic: no pre-reveal anchors). - New dd_same_scenario_annuity lineage: the NPV annuity / fixed-cost window walks dedup branch copies of the same CALENDAR slot by anchor, so a shared trunk's window counts each future period once (window {p2035,p2040}=2), not once per branch (=3). Reconciles design §4.1 (capacity edd keeps the full frame with (p2035,p2040_low)) with §8.1 (annuity=2). Fixes the latent NPV annuity over-count that the shared trunk exposes. No-op for every non-shared-trunk solve. - check_recourse_npv_preconditions obligation (i): re-scope leaf-weight constancy to the POST-REVEAL segment (exclude shared pre-reveal anchors, which carry the certain weight 1.0) per the function's own Slice-E note; the two-stage weighting is correct, not a violation. Fixture stoch_two_period_hedge (RP mid-horizon 173250 / WS fan-at-first 157500) + conftest wiring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 145 +- flextool/engine_polars/_derived_npv.py | 10 +- tests/conftest.py | 26 + .../fixtures/build_stoch_two_period_hedge.py | 287 + tests/fixtures/stoch_two_period_hedge.json | 6130 +++++++++++++++++ 5 files changed, 6587 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/build_stoch_two_period_hedge.py create mode 100644 tests/fixtures/stoch_two_period_hedge.json diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index ac8a5e15e..0fb7a5d43 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -835,9 +835,15 @@ def _lineage_leaves( synthetic: list[str], anchor_of: dict[str, str], tb_of: dict[str, str], -) -> list[list[str]]: +) -> list[tuple[str, list[str]]]: """Build the solve's scenario leaves (design §2.2). + Returns ``(label, members)`` pairs (F4, Slice E design §6.2): the + trunk leaf labeled ``"__realized"`` and each branch leaf labeled its + time-branch id — so a caller that keys constraint rows off the leaf + (``d_leaf``) carries the authoritative label rather than re-deriving + and zipping it (a brittle two-independent-iteration alignment). + Trunk leaf = all anchors (period order). One leaf per time-branch possessing at least one synthetic ``period_in_use`` member (F3 — never iterate SBTB's own time-branches: SBTB also carries the @@ -845,9 +851,10 @@ def _lineage_leaves( leaf, the per-period representative follows the F6 authority rule: the branch-local member is authoritative; a real anchor joins the leaf only when it is strictly pre-reveal AND the branch has no - member for that period. + member for that period (Slice E Option B: a shared pre-reveal trunk + period thus belongs to EVERY branch leaf's path). """ - leaves: list[list[str]] = [list(anchors)] + leaves: list[tuple[str, list[str]]] = [("__realized", list(anchors))] branch_tbs = dict.fromkeys(tb_of[m] for m in synthetic) for br in branch_tbs: members = [m for m in synthetic if tb_of[m] == br] @@ -857,7 +864,7 @@ def _lineage_leaves( leaf = sorted(pre + members, key=lambda x: pos[x] if x in pos else pos[anchor_of[x]]) - leaves.append(leaf) + leaves.append((br, leaf)) return leaves @@ -933,7 +940,7 @@ def dd_same_scenario_lf( pb_rows, piu, tb_of) leaves = _lineage_leaves(anchors, pos, synthetic, anchor_of, tb_of) seen: dict[tuple[str, str], None] = {} - for leaf in leaves: + for _label, leaf in leaves: for x in leaf: for y in leaf: seen[(x, y)] = None @@ -957,6 +964,94 @@ def dd_same_scenario_df( ctx=ctx, provider=provider).collect() +def dd_same_scenario_annuity_lf( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.LazyFrame: + """``dd_same_scenario`` for the NPV ANNUITY / fixed-cost window walks + — the capacity frame :func:`dd_same_scenario_lf` with branch copies + of the SAME calendar slot collapsed to one representative on the + ``d_other`` side, per anchor ``d`` (Slice E design §4.1 vs §8.1 + reconciliation). + + The capacity lineage (``dd_same_scenario``, consumed by the ``edd`` + walks) must put a shared pre-reveal trunk period on EVERY branch + leaf's path — e.g. ``(p2035, p2040_low)`` present — so the shared + first-stage capacity is alive in every scenario's dispatch. But the + ANNUITY window sums an inflation factor over the leaf's periods: if + the shared trunk's window kept BOTH ``p2040`` and ``p2040_low`` (the + same calendar year 2040 under two mutually-exclusive scenarios) it + would charge the first-stage capacity THREE period-annuities instead + of two — the latent NPV over-count. The capacity is physically alive + for ONE second-stage calendar period; the scenario only affects + dispatch, not the certain capacity payment. So for the annuity we + keep one member per CALENDAR anchor (preferring the real-named + anchor, whose inflation factor is authoritative), giving the shared + trunk the window ``{p2035, p2040}`` → Σ = 2. + + Byte-parity: deterministic / fan-at-first / single-period solves have + no shared trunk — no anchor's leaf-union carries two members with the + same calendar anchor — so the dedup is a no-op and the frame equals + :func:`dd_same_scenario_lf`. Deterministic periods that happen to + share a calendar year are DISTINCT anchors, so they are never + collapsed (the dedup keys on the anchor, not the year). + """ + schema = {"d": schema_dtype(_enums, "d"), + "d_other": schema_dtype(_enums, "d")} + pb_rows, piu, tb_of = _lineage_inputs( + workdir, source, active_solve, ctx=ctx, provider=provider) + if not piu: + return _empty_lf(schema) + anchors, pos, synthetic, anchor_of = _classify_lineage( + pb_rows, piu, tb_of) + leaves = _lineage_leaves(anchors, pos, synthetic, anchor_of, tb_of) + # Calendar anchor of every in-use period (anchors map to themselves). + calendar_anchor = {a: a for a in anchors} + for m in synthetic: + calendar_anchor[m] = anchor_of[m] + # Gather every d_other reachable from d across ALL leaves (a shared + # trunk sits on more than one leaf, so its reachable set unions the + # branches — exactly the over-count source). + reachable: dict[str, dict[str, None]] = {} + for _label, leaf in leaves: + for x in leaf: + bucket = reachable.setdefault(x, {}) + for y in leaf: + bucket[y] = None + seen: dict[tuple[str, str], None] = {} + for x, ys in reachable.items(): + best: dict[str, str] = {} + for y in ys: + a = calendar_anchor[y] + # Prefer the real-named anchor member as the representative; + # otherwise the first-seen member for that calendar slot. + if a not in best or y == a: + best[a] = y + for y in best.values(): + seen[(x, y)] = None + if not seen: + return _empty_lf(schema) + return _finalize_lineage_frame(list(seen), "d", "d", "d_other", + "d_other", "dd_same_scenario_annuity") + + +def dd_same_scenario_annuity_df( + workdir: Path | None, + source: "InputSource | None" = None, + active_solve: str | None = None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> pl.DataFrame: + """Collect wrapper for :func:`dd_same_scenario_annuity_lf`.""" + return dd_same_scenario_annuity_lf(workdir, source, active_solve, + ctx=ctx, provider=provider).collect() + + def d_leaf_lf( workdir: Path | None, source: "InputSource | None" = None, @@ -992,10 +1087,21 @@ def d_leaf_lf( workdir, source, active_solve, ctx=ctx, provider=provider) if not piu: return _empty_lf(schema) - anchors, _pos, synthetic, _anchor_of = _classify_lineage( + anchors, pos, synthetic, anchor_of = _classify_lineage( pb_rows, piu, tb_of) - rows: list[tuple[str, str]] = [(a, "__realized") for a in anchors] - rows += [(m, tb_of[m]) for m in synthetic] + # Partition → RELATION (Slice E design §6.2). Under Option B a shared + # pre-reveal trunk anchor belongs to EVERY scenario leaf's path, so it + # must count into every leaf's per-path total cap — the strict + # partition (anchor → __realized only) would under-count the branch + # leaves' totals. Rebuild from ``_lineage_leaves`` (the same relation + # ``dd_same_scenario`` uses), carrying the authoritative leaf label + # (F4). Byte-parity: deterministic / fan-at-first solves have no + # pre-reveal anchors, so each branch leaf gains nothing and the map is + # identical to the legacy partition. + labeled = _lineage_leaves(anchors, pos, synthetic, anchor_of, tb_of) + rows: list[tuple[str, str]] = [] + for label, members in labeled: + rows += [(d, label) for d in members] raw = pl.DataFrame({"d": [r[0] for r in rows], "leaf": [r[1] for r in rows]}, schema={"d": pl.Utf8, "leaf": pl.Utf8}) @@ -1272,10 +1378,31 @@ def check_recourse_npv_preconditions( # --- (i) per-leaf weight constancy ------------------------------------ # Member chains = PIU representatives grouped by SBTB time-branch. + # + # Slice E mid-horizon re-scoping (design §5, §9.3; the note at the head + # of this function): under Option B a SHARED pre-reveal trunk anchor is + # a cohort-of-one first-stage decision carrying weight 1.0, while the + # post-reveal members of the same (realized) chain carry the scenario + # weight (e.g. 0.25). That is the CORRECT two-stage weighting — the + # first stage is certain, the recourse is probability-weighted — not a + # violation. Constancy is therefore required only over the POST-REVEAL + # segment of each chain; the shared pre-reveal anchors are excluded. + # For fan-at-first-step / deterministic solves there are no pre-reveal + # anchors, so the check is byte-unchanged. + anchors_i, pos_i, synthetic_i, anchor_of_i = _classify_lineage( + pb_rows, piu, tb_of) + pre_reveal_anchors: set[str] = set() + if synthetic_i: + for br in dict.fromkeys(tb_of[m] for m in synthetic_i): + br_members = [m for m in synthetic_i if tb_of[m] == br] + k_reveal = min(pos_i[anchor_of_i[m]] for m in br_members) + for a in anchors_i: + if pos_i[a] < k_reveal: + pre_reveal_anchors.add(a) chains: dict[str, list[str]] = {} for m in piu: tb = tb_of.get(m) - if tb is not None: + if tb is not None and m not in pre_reveal_anchors: chains.setdefault(tb, []).append(m) for tb, members in chains.items(): present = [(m, weights[m]) for m in members if m in weights] diff --git a/flextool/engine_polars/_derived_npv.py b/flextool/engine_polars/_derived_npv.py index 4a9d357fb..19b92362c 100644 --- a/flextool/engine_polars/_derived_npv.py +++ b/flextool/engine_polars/_derived_npv.py @@ -1303,9 +1303,15 @@ def apply_npv(flex_data: object, if _recourse_branch_axis_present(): from ._derived_branch import ( assert_recourse_npv_preconditions, - dd_same_scenario_df, + dd_same_scenario_annuity_df, ) - _npv_lineage = dd_same_scenario_df( + # Slice E (§4.1 vs §8.1): the NPV annuity / fixed-cost window + # walks use the CALENDAR-anchor-deduplicated lineage so a shared + # pre-reveal trunk's window counts each future calendar period + # once (not once per branch) — the capacity edd walks keep the + # full ``dd_same_scenario``. Byte-parity for every non-shared- + # trunk solve (the dedup is a no-op there). + _npv_lineage = dd_same_scenario_annuity_df( workdir, source, active_solve, provider=provider) assert_recourse_npv_preconditions( workdir, source, active_solve, provider=provider) diff --git a/tests/conftest.py b/tests/conftest.py index a306c8ac8..addd1235d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -535,6 +535,29 @@ def stoch_two_period_invest_na_db_url( return url +@pytest.fixture(scope="session") +def stoch_two_period_hedge_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_hedge`` fixture DB — Slice E hedged two-stage + (mid-horizon reveal) gate (design ``specs/sliceE_invest_na_design.md`` + §8). + + Built from ``tests/fixtures/stoch_two_period_hedge.json`` (generated + by ``tests/fixtures/build_stoch_two_period_hedge.py``): two scenarios + (``hedge`` = mid-horizon reveal at p2040 → RP 173 250; ``hedge_ws`` = + wait-and-see fan-at-p2035 → WS 157 500) on identical scenario data. + Defensive migration as in :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stph") + / "stoch_two_period_hedge.sqlite") + url = json_to_db(FIXTURES_DIR / "stoch_two_period_hedge.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -587,6 +610,7 @@ def case14_db_url(tmp_path_factory: pytest.TempPathFactory) -> str: "stoch_two_period": "stoch_two_period_db_url", "stoch_two_period_invest": "stoch_two_period_invest_db_url", "stoch_two_period_invest_na": "stoch_two_period_invest_na_db_url", + "stoch_two_period_hedge": "stoch_two_period_hedge_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -631,6 +655,7 @@ def scenario_workdir( stoch_two_period_db_url, stoch_two_period_invest_db_url, stoch_two_period_invest_na_db_url, + stoch_two_period_hedge_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -688,6 +713,7 @@ def test_foo(scenario_workdir): "stoch_two_period": stoch_two_period_db_url, "stoch_two_period_invest": stoch_two_period_invest_db_url, "stoch_two_period_invest_na": stoch_two_period_invest_na_db_url, + "stoch_two_period_hedge": stoch_two_period_hedge_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/fixtures/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py new file mode 100644 index 000000000..714795b6a --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -0,0 +1,287 @@ +"""Build ``tests/fixtures/stoch_two_period_hedge.json`` — the Slice E +hedged two-stage (mid-horizon reveal) fixture (design +``specs/sliceE_invest_na_design.md`` §8). + +Two 3-step periods ``p2035`` (t0001-t0003) / ``p2040`` (t0004-t0006). The +lever that makes hedging valuable: a period-Map ``invest_cost`` +``{p2035: 1, p2040: 3}`` so the FIRST-STAGE (p2035) capacity is cheaper per +delivered p2040-MW than the recourse (p2040) capacity — under +``reinvest_automatic`` p2035 capacity is alive at p2040 (unbounded-forward +windows), so pre-positioning under uncertainty pays off. + +Two scenarios drive the WS < RP < EEV proof on IDENTICAL scenario data, +differing ONLY in WHERE the branches are declared: + + * ``hedge`` — reveal at **p2040** (mid-horizon). ``v_invest[base, + p2035]`` is a single SHARED first-stage decision (Option B: the shared + trunk, NA by construction). Hand-calc objective **173 250** (RP). + * ``hedge_ws`` — reveal at **p2035** (fan-at-first-step, the Slice-D + wait-and-see topology). ``v_invest`` is scenario-specific; each + scenario builds all-first-stage. Hand-calc objective **157 500** (WS). + +``RP − WS = 15 750 = EVPI > 0`` — a direct behavioural proof that the +mid-horizon first stage is genuinely shared (§8.3). + +Effective demand seen by the investable ``base`` unit (design §8.1: p2035 = +40 shared; p2040 realized/high = 120, low = 60) is produced supply-side — +NO stochastic NODE, so the LP stays separable / storage-free and the +hand-calc is tractable (mirrors ``build_stoch_two_period_invest`` §8's +mechanism note). A free existing ``wind`` unit with a branch-varying +``upper_limit`` profile carves the residual that ``base`` must cover: + + * node ``elec`` demand = 120 MW at every step (deterministic inflow). + * wind existing 100 MW, cost 0; profile (share of 100 MW): + - p2035 (pre-reveal, SHARED across branches): 0.8 → wind 80 → + base residual 40 (= D1). + - p2040 realized (rlz): 0.0 → wind 0 → base residual 120 (= D2 high). + - p2040 low: 0.6 → wind 60 → base residual 60 (= D2 low). + * unit ``base`` → ``elec`` — the investable peaker (virtual_unitsize 1, + invest_total, discount_rate 0.05, lifetime 1 → annuity per MW-period + = cost·1000·CRF(0.05,1) = cost·1050; unbounded-forward windows so the + p2035 anchor's window is {p2035, p2040} → K1 = 2·1050 = 2100 and the + p2040 anchor's window is {p2040} → K2 = 1·1050 = 3150), + ``invest_max_total`` 150 (non-binding; the per-path cap witness §8.3). + +**No inflation / no solve-level discount** (design §8.1 F3): the exact +173 250 golden requires ``inv_factor ≡ 1.0``, i.e. the SOLVE-level +inflation scalar = 0. This builder authors NO ``model.inflation_rate``, +NO ``inflation_offset_investment/operations``, and NO solve-level +``discount_rate`` — all left at their 0.0 default. The entity +``discount_rate = 0.05`` on ``base`` drives ONLY the CRF (the annuity), a +distinct knob. + +Hand calculation (design §8.2), ``x = v_invest[base, p2035]``, +``r_s = v_invest[base, p2040_s]``, weights ``w_rlz = 0.25`` (D2 = 120), +``w_low = 0.75`` (D2 = 60): + + RP = min_x 2100·x + Σ_s w_s·3150·(D2_s − x)^+ + → x* = 60 (hedge between 60 and 120, above D1 = 40) + → 2100·60 + 0.25·3150·60 = 126 000 + 47 250 = 173 250. + WS = 0.75·(2100·60) + 0.25·(2100·120) = 94 500 + 63 000 = 157 500. + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_two_period_hedge.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 7)] +P1_STEPS = STEPS[:3] +P2_STEPS = STEPS[3:] + + +def _pack(value: object, value_type: str) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + src = json.loads((FIXTURES / "stochastics.json").read_text()) + + spec: dict = { + key: src[key] + for key in ( + "entity_classes", + "parameter_value_lists", + "parameter_groups", + "parameter_definitions", + "parameter_types", + ) + } + + # ``stochastics.json`` still carries the pre-v70 schema, so inject the + # ``solve.stochastic_invest_method`` opt-in (Slice C, v70) before + # import (json_to_db validates against the embedded schema BEFORE the + # idempotent v70 migration reconciles it). + if not any(vl[0] == "stochastic_invest_methods" + for vl in spec["parameter_value_lists"]): + spec["parameter_value_lists"] += [ + ["stochastic_invest_methods", _pack("none", "str")], + ["stochastic_invest_methods", _pack("recourse", "str")], + ] + if not any(pd[0] == "solve" and pd[1] == "stochastic_invest_method" + for pd in spec["parameter_definitions"]): + spec["parameter_definitions"].append([ + "solve", "stochastic_invest_method", + _pack("none", "str"), "stochastic_invest_methods", + "How stochastic branches participate in investment " + "(none/recourse).", "solve_advanced", + ]) + + spec["alternatives"] = [ + ["init", "Model, solve and time structure"], + ["base", "System data (base invest + wind + elec demand)"], + ["stoch", "include_stochastics + branch-varying wind + recourse"], + ["rp", "stochastic_branches at p2040 (mid-horizon reveal)"], + ["ws", "stochastic_branches at p2035 (wait-and-see / fan-at-first)"], + ] + spec["scenarios"] = [ + ["hedge", False, "Mid-horizon reveal at p2040 (RP = 173 250)"], + ["hedge_ws", False, + "Wait-and-see fan-at-p2035 control (WS = 157 500)"], + ] + spec["scenario_alternatives"] = [ + ["hedge", "init", "base"], + ["hedge", "base", "stoch"], + ["hedge", "stoch", "rp"], + ["hedge", "rp", None], + ["hedge_ws", "init", "base"], + ["hedge_ws", "base", "stoch"], + ["hedge_ws", "stoch", "ws"], + ["hedge_ws", "ws", None], + ] + + spec["entities"] = [ + ["group", "stoch_g", None], + ["model", "flextool", None], + ["node", "elec", None], + ["profile", "wprof", None], + ["solve", "stoch_2p_hedge", None], + ["timeline", "y6h", None], + ["timeset", "ts1", None], + ["timeset", "ts2", None], + ["unit", "base", None], + ["unit", "wind", None], + ["group__unit", ["stoch_g", "wind"], None], + ["unit__outputNode", ["base", "elec"], None], + ["unit__outputNode", ["wind", "elec"], None], + ["unit__node__profile", ["wind", "elec", "wprof"], None], + ] + spec["entity_alternatives"] = [ + ["node", ["elec"], "init", True], + ["unit", ["base"], "init", True], + ["unit", ["wind"], "init", True], + ] + + # ---- init: model / solve / time structure -------------------------- + # Mid-horizon reveal (scenario ``hedge``): branches declared at p2040. + stochastic_branches_rp = _map([ + ("p2040", _nested([ + ("rlz", _nested([("t0004", _nested([("yes", 1.0)]))])), + ("low", _nested([("t0004", _nested([("no", 3.0)]))])), + ])), + ]) + # Wait-and-see (scenario ``hedge_ws``): branches declared at p2035. + stochastic_branches_ws = _map([ + ("p2035", _nested([ + ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 3.0)]))])), + ])), + ]) + # Deterministic profile (base alternative) — matches the rlz branch: + # 0.8 at p2035 (wind 80 → residual 40), 0.0 at p2040 (residual 120). + profile_base = _map( + [(t, 0.80) for t in P1_STEPS] + [(t, 0.0) for t in P2_STEPS] + ) + # Branch-varying wind profile (stoch alternative). Keyed + # branch → period-first-step → per-step share of the 100 MW wind cap. + # p2035 (t0001) is SHARED (0.8 in BOTH branches — pre-reveal data must + # be identical; under mid-horizon only the realized/trunk value is + # ever read). p2040 (t0004): rlz 0.0 (residual 120), low 0.6 + # (residual 60). + profile_stoch = _map([ + ("rlz", _nested([ + ("t0001", _nested([(t, 0.80) for t in P1_STEPS])), + ("t0004", _nested([(t, 0.0) for t in P2_STEPS])), + ])), + ("low", _nested([ + ("t0001", _nested([(t, 0.80) for t in P1_STEPS])), + ("t0004", _nested([(t, 0.60) for t in P2_STEPS])), + ])), + ]) + # Deterministic demand: 120 MW at every step (both periods). + inflow = _map([(t, -120.0) for t in STEPS]) + # Period-Map invest_cost: first stage cheap (1), recourse dear (3). + invest_cost = _map([("p2035", 1.0), ("p2040", 3.0)]) + + spec["parameter_values"] = [ + ["model", "flextool", "solves", + _pack({"value_type": "str", "data": ["stoch_2p_hedge"]}, "array"), + "init"], + ["timeline", "y6h", "timestep_duration", + _pack(_map([(t, 1.0) for t in STEPS]), "map"), "init"], + ["timeset", "ts1", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts1", "timeset_duration", + _pack(_map([("t0001", 3.0)]), "map"), "init"], + ["timeset", "ts2", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts2", "timeset_duration", + _pack(_map([("t0004", 3.0)]), "map"), "init"], + ["solve", "stoch_2p_hedge", "period_timeset", + _pack(_map([("p2035", "ts1"), ("p2040", "ts2")]), "map"), "init"], + ["solve", "stoch_2p_hedge", "realized_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_hedge", "invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_hedge", "realized_invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_hedge", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_2p_hedge", "years_represented", + _pack(_map([("p2035", 1.0), ("p2040", 1.0)]), "map"), "init"], + # ---- base: system data ---------------------------------------- + ["node", "elec", "node_type", _pack("balance", "str"), "base"], + ["node", "elec", "penalty_up", _pack(10000.0, "float"), "base"], + ["node", "elec", "penalty_down", _pack(10000.0, "float"), "base"], + ["node", "elec", "inflow", _pack(inflow, "map"), "base"], + ["unit", "wind", "existing", _pack(100.0, "float"), "base"], + ["unit", "wind", "efficiency", _pack(1.0, "float"), "base"], + ["unit__node__profile", ["wind", "elec", "wprof"], "profile_method", + _pack("upper_limit", "str"), "base"], + ["profile", "wprof", "profile", _pack(profile_base, "map"), "base"], + ["unit", "base", "efficiency", _pack(1.0, "float"), "base"], + ["unit", "base", "existing", _pack(0.0, "float"), "base"], + ["unit", "base", "virtual_unitsize", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_method", + _pack("invest_total", "str"), "base"], + ["unit", "base", "invest_cost", _pack(invest_cost, "map"), "base"], + ["unit", "base", "discount_rate", _pack(0.05, "float"), "base"], + ["unit", "base", "lifetime", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_max_total", _pack(150.0, "float"), + "base"], + ["unit__outputNode", ["base", "elec"], "other_operational_cost", + _pack(0.0, "float"), "base"], + # ---- stoch: branches + branch-varying profile + recourse ------- + ["group", "stoch_g", "include_stochastics", _pack("yes", "str"), + "stoch"], + ["profile", "wprof", "profile", _pack(profile_stoch, "map"), + "stoch"], + ["solve", "stoch_2p_hedge", "stochastic_invest_method", + _pack("recourse", "str"), "stoch"], + # ---- rp: mid-horizon reveal at p2040 --------------------------- + ["solve", "stoch_2p_hedge", "stochastic_branches", + _pack(stochastic_branches_rp, "map"), "rp"], + # ---- ws: wait-and-see reveal at p2035 -------------------------- + ["solve", "stoch_2p_hedge", "stochastic_branches", + _pack(stochastic_branches_ws, "map"), "ws"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_hedge.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json new file mode 100644 index 000000000..e261ca64f --- /dev/null +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -0,0 +1,6130 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "Njk=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment (none/recourse).", + "solve_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solve and time structure" + ], + [ + "base", + "System data (base invest + wind + elec demand)" + ], + [ + "stoch", + "include_stochastics + branch-varying wind + recourse" + ], + [ + "rp", + "stochastic_branches at p2040 (mid-horizon reveal)" + ], + [ + "ws", + "stochastic_branches at p2035 (wait-and-see / fan-at-first)" + ] + ], + "scenarios": [ + [ + "hedge", + false, + "Mid-horizon reveal at p2040 (RP = 173 250)" + ], + [ + "hedge_ws", + false, + "Wait-and-see fan-at-p2035 control (WS = 157 500)" + ] + ], + "scenario_alternatives": [ + [ + "hedge", + "init", + "base" + ], + [ + "hedge", + "base", + "stoch" + ], + [ + "hedge", + "stoch", + "rp" + ], + [ + "hedge", + "rp", + null + ], + [ + "hedge_ws", + "init", + "base" + ], + [ + "hedge_ws", + "base", + "stoch" + ], + [ + "hedge_ws", + "stoch", + "ws" + ], + [ + "hedge_ws", + "ws", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_hedge", + null + ], + [ + "timeline", + "y6h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "timeset", + "ts2", + null + ], + [ + "unit", + "base", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "base" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF8ycF9oZWRnZSJdfQ==", + "array" + ], + "init" + ], + [ + "timeline", + "y6h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl0sIFsicDIwNDAiLCAidHMyIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_2p_hedge", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMS4wXV19", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTIwLjBdLCBbInQwMDAyIiwgLTEyMC4wXSwgWyJ0MDAwMyIsIC0xMjAuMF0sIFsidDAwMDQiLCAtMTIwLjBdLCBbInQwMDA1IiwgLTEyMC4wXSwgWyJ0MDAwNiIsIC0xMjAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjhdLCBbInQwMDAyIiwgMC44XSwgWyJ0MDAwMyIsIDAuOF0sIFsidDAwMDQiLCAwLjBdLCBbInQwMDA1IiwgMC4wXSwgWyJ0MDAwNiIsIDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "base", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "base", + "invest_cost", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMy4wXV19", + "map" + ], + "base" + ], + [ + "unit", + "base", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + "other_operational_cost", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuOF0sIFsidDAwMDIiLCAwLjhdLCBbInQwMDAzIiwgMC44XV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4wXSwgWyJ0MDAwNSIsIDAuMF0sIFsidDAwMDYiLCAwLjBdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjhdLCBbInQwMDAyIiwgMC44XSwgWyJ0MDAwMyIsIDAuOF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuNl0sIFsidDAwMDUiLCAwLjZdLCBbInQwMDA2IiwgMC42XV19XV19XV19", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_invest_method", + [ + "InJlY291cnNlIg==", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwNDAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "rp" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "ws" + ] + ] +} From 1f6f26bfe9396b551d18f4f60523441dd82d0bd5 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 13:29:23 +0300 Subject: [PATCH 25/87] test(stochastic): E4 hedging gate (173250) + storage scenario + docs - test_recourse_invest_hedge.py: T-RP (173250, hedge x=60/r=60/0), T-WS (157500, EVPI=15750>0), T-EEV (192937.5 hand-calc, VSS>0), T-F/T-V (mid-horizon fan + validation, solver-free unit harness), T-L/T-E2 (dd_same_scenario shape A, d_leaf relation, pd_non_anticipativity empty, no invest-NA family), T-C (per-path cap two-leaf), T-S (storage NA fires on (p2040,p2040_low), branch copy links to shared trunk, make_step_jump <-> dtttdt parity). - hedge fixture gains a hedge_storage scenario (stochastic-group storage node resv) for the storage-NA / continuity assertions. - docs/how_to.md + CHANGELOG: mid-horizon hedged two-stage reveal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- CHANGELOG.md | 16 +- docs/how_to.md | 8 + .../test_recourse_invest_hedge.py | 465 ++++++++++++++++++ .../fixtures/build_stoch_two_period_hedge.py | 42 ++ tests/fixtures/stoch_two_period_hedge.json | 213 ++++++++ 5 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 tests/engine_polars/test_recourse_invest_hedge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ee78140..4467c8240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,19 @@ Schema change (adds `solve.stochastic_invest_method`; migrated automatically). wait-and-see analysis giving per-scenario optimal plans and their expected cost. Committed results report the realized scenario; non-realized branch investments are available under the horizon-output debug flag - (`model.output_horizon`). Shared (hedged) pre-branch investment is a planned - follow-up. Default behaviour (`none`) is unchanged, and the option is - rejected in combination with Benders decomposition. + (`model.output_horizon`). Default behaviour (`none`) is unchanged, and the + option is rejected in combination with Benders decomposition. +- **Mid-horizon reveal / hedged two-stage stochastics.** A stochastic branch + may now start at any period boundary, not only the solve's first step, so the + pre-reveal periods stay a single shared *here-and-now* investment (a genuine + first stage) while the branches fan out into per-scenario recourse from the + reveal onward. Under `recourse` this makes the objective a real two-stage + hedge — strictly between the wait-and-see bound and the deterministic + mean-value plan (positive EVPI/VSS). Storage state is carried across the + reveal for stochastic-group storage nodes. Mid-horizon reveal is a general + stochastics capability; the previous multi-period restriction is lifted. A + malformed reveal (analysis time off a period boundary, or a branching period + without exactly one realized branch) is now rejected with a clear error. ## Release 4.0.4 (21.9.2026) — VRE curtailment output & solver option fixes diff --git a/docs/how_to.md b/docs/how_to.md index 6a2fad320..8b23ef2fd 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1143,6 +1143,14 @@ Read the results with these three points in mind: `recourse` is incompatible with Benders decomposition (the solver rejects the combination). +#### Hedged two-stage investment (mid-horizon reveal) + +The wait-and-see behaviour above comes from declaring every branch at the solve's **first** step, so each scenario has perfect foresight from the start. You can instead reveal the uncertainty **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). + +With this shape the pre-reveal periods stay a single real-named trunk with **one shared `v_invest`** — the genuine first-stage (here-and-now) decision. Its non-anticipativity holds by construction (there is only one variable, so nothing to tie), and its cost is counted once at full weight. The post-reveal periods fan per branch exactly as the wait-and-see case does, giving the second-stage recourse investments. The result is a real **two-stage hedge**: the shared first stage is chosen knowing only the distribution of futures, and its objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS). Only the realized trunk is committed to the output. + +The multi-period restriction of earlier versions is lifted: a branch may now start at any period boundary, and the pre-reveal horizon can be any number of periods. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. + In this example, we show two ways to use stochastics: Single solve, rolling horizon. They all share the same test system that includes: - A demand node diff --git a/tests/engine_polars/test_recourse_invest_hedge.py b/tests/engine_polars/test_recourse_invest_hedge.py new file mode 100644 index 000000000..c078f81e4 --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_hedge.py @@ -0,0 +1,465 @@ +"""Slice E — hedged two-stage stochastic investment (mid-horizon reveal). + +Design: ``specs/sliceE_invest_na_design.md`` (§3 fan-out, §5 storage +linkage, §6 per-path caps, §7 invest-NA-by-construction, §8 hedging +hand-calc). + +Fixture ``stoch_two_period_hedge.json`` (built via +``build_stoch_two_period_hedge.py``): two 3-step periods p2035 / p2040, +period-Map ``invest_cost`` {p2035: 1, p2040: 3} so the first stage is +cheaper per delivered p2040-MW; a free ``wind`` unit's branch-varying +``upper_limit`` profile carves the residual the investable ``base`` unit +must cover (p2035 = 40 shared; p2040 realized/high = 120, low = 60). + +Three scenarios on identical scenario data: + * ``hedge`` — mid-horizon reveal at p2040 (RP = 173 250). + * ``hedge_ws`` — wait-and-see fan-at-p2035 control (WS = 157 500). + * ``hedge_storage`` — mid-horizon + a stochastic-group storage node + (``resv``) so the dispatch storage-NA + continuity linkage fire. + +Headline (§8.3): WS = 157 500 < RP = 173 250 < EEV = 192 937.5, so +EVPI = RP − WS = 15 750 > 0 and VSS = EEV − RP = 19 687.5 > 0 — genuine +hedging value. The RP ≠ WS gap is a direct behavioural proof that the +mid-horizon first stage ``v_invest[base, p2035]`` is genuinely SHARED +(Option B) — if E0 accidentally fanned p2035 the objective would collapse +to WS. +""" +from __future__ import annotations + +import logging +import os +from collections import defaultdict +from types import SimpleNamespace + +import polars as pl +import pytest + +from flextool.engine_polars._solve_state import ( + ActiveTimeEntry, + FlexToolConfigError, +) +from flextool.engine_polars._stochastic import StochasticSolver +from flextool.engine_polars._timeline import make_step_jump + +SOLVE = "stoch_2p_hedge" + +# Hand-calc headline numbers (design §8). +RP = 173_250.0 # hedged mid-horizon reveal +WS = 157_500.0 # wait-and-see (fan-at-p2035) +EEV = 192_937.5 # deterministic mean-value decision, evaluated +EVPI = RP - WS # = 15 750 +VSS = EEV - RP # = 19 687.5 + + +# --------------------------------------------------------------------------- +# Unit harness (solver-free) — create_stochastic_periods + make_step_jump +# --------------------------------------------------------------------------- + + +def _ats(pairs): + return [ActiveTimeEntry(timestep=t, index=i, duration=1.0) + for t, i in pairs] + + +P1 = _ats([("t0001", 0), ("t0002", 1), ("t0003", 2)]) +P2 = _ats([("t0004", 3), ("t0005", 4), ("t0006", 5)]) + + +def _make_solver() -> StochasticSolver: + state = SimpleNamespace( + logger=logging.getLogger("test_recourse_invest_hedge"), + timeline=SimpleNamespace(stochastic_timesteps=defaultdict(list)), + ) + return StochasticSolver(state) + + +def _run(solver, info): + active = {"p2035": P1, "p2040": P2} + realized = {"p2035": P1[0:], "p2040": P2[0:]} + fix_storage = {"p2040": P2[0:]} + return solver.create_stochastic_periods( + {"s": info}, ["s"], {"s": "s"}, + {"s": active}, {"s": fix_storage}, {"s": realized}, + ) + + +# --- T-F: mid-horizon fan-out is Option B (shared trunk) -------------------- + +# stochastic_branches rows: (period, branch, start_step, realized, weight). +MIDHORIZON_INFO = [ + ("p2040", "rlz", "t0004", "yes", "1"), + ("p2040", "low", "t0004", "no", "3"), +] + + +def test_midhorizon_fan_period_branch_and_active_time() -> None: + """T-F (§3.2): a reveal declared at p2040 keeps p2035 a single + real-named trunk period and fans ONLY p2040 — exactly Option B.""" + solver = _make_solver() + pb, sbtb, atl, _jumps, _fstl, _rtl, bstl = _run(solver, MIDHORIZON_INFO) + + # p2035 is a single trunk self-row (NOT fanned); p2040 fans. + assert pb["s"] == [ + ("p2035", "p2035"), + ("p2040", "p2040"), + ("p2040", "p2040_rlz"), + ("p2040", "p2040_low"), + ] + # Active-time keys: p2035 trunk, p2040 realized, p2040_low branch copy. + assert list(atl["s"].keys()) == ["p2035", "p2040", "p2040_low"] + assert atl["s"]["p2035"] == P1 # trunk unchanged + assert atl["s"]["p2040_low"] == P2[0:] # branch copy of p2040 + # Realized resolution: trunk p2035 -> rlz (via the after-branch path), + # p2040 -> rlz, p2040_low -> low. + assert ("p2035", "rlz") in sbtb["s"] + assert ("p2040", "rlz") in sbtb["s"] + assert ("p2040_low", "low") in sbtb["s"] + # The reveal is recorded at p2040 / t0004. + assert bstl["s"] == ("p2040", "t0004") + + +# --- T-V: validation (period-boundary reveal, one realized per period) ------ + + +def test_validation_midperiod_start_raises() -> None: + """T-V (§3.3 check 1): a branch start_step that is not the first step + of its period is rejected (invest is period-granular).""" + solver = _make_solver() + bad = [ + ("p2040", "rlz", "t0004", "yes", "1"), + ("p2040", "low", "t0005", "no", "3"), # not p2040's first step + ] + with pytest.raises(FlexToolConfigError, match="first step"): + _run(solver, bad) + + +def test_validation_two_realized_in_period_raises() -> None: + """T-V (§3.3 check 2): a branching period with two realized:yes + branches is rejected.""" + solver = _make_solver() + bad = [ + ("p2040", "rlz", "t0004", "yes", "1"), + ("p2040", "low", "t0004", "yes", "3"), # two realized + ] + with pytest.raises(FlexToolConfigError, match="exactly one realized"): + _run(solver, bad) + + +def test_validation_zero_realized_in_period_raises() -> None: + """T-V (§3.3 check 2): a branching period with no realized branch is + rejected (subsumes the old 'no realized start time' guard).""" + solver = _make_solver() + bad = [ + ("p2040", "rlz", "t0004", "no", "1"), + ("p2040", "low", "t0004", "no", "3"), + ] + with pytest.raises(FlexToolConfigError, match="exactly one realized"): + _run(solver, bad) + + +def test_validation_fan_at_first_still_passes() -> None: + """T-V control: the fan-at-first-step shape (branch at the solve's + first step) still validates and fans byte-identically.""" + solver = _make_solver() + good = [ + ("p2035", "rlz", "t0001", "yes", "1"), + ("p2035", "low", "t0001", "no", "3"), + ] + pb, _sbtb, atl, *_ = _run(solver, good) + assert list(atl["s"].keys()) == [ + "p2035", "p2035_low", "p2040", "p2040_low", + ] + assert ("p2035", "p2035_low") in pb["s"] + + +# --- T-S (jump): storage-continuity predecessor + fan-at-first control ------ + +# make_step_jump tuple = (period, timestep, previous, +# previous_within_timeset, previous_period, previous_within_solve, jump). +_PREV_PERIOD = 4 +_PREV_WITHIN_SOLVE = 5 + + +def _first_step_rows(mj): + return {(r[0], r[1]): r for r in mj if r[1] in ("t0001", "t0004")} + + +def test_step_jump_midhorizon_links_branch_to_shared_trunk() -> None: + """T-S (§5.2, F1): the mid-horizon branch copy p2040_low's first step + links to the shared pre-reveal trunk p2035's LAST step (t0003) — NOT + a self-cycle, and NOT its own realized sibling p2040.""" + atl = {"p2035": P1, "p2040": P2, "p2040_low": P2} + pb = [("p2035", "p2035"), ("p2040", "p2040"), + ("p2040", "p2040_rlz"), ("p2040", "p2040_low")] + sbtb = [("p2040_low", "low"), ("p2035", "rlz"), ("p2040", "rlz")] + rows = _first_step_rows(make_step_jump(atl, pb, sbtb)) + branch = rows[("p2040_low", "t0004")] + assert branch[_PREV_PERIOD] == "p2035" + assert branch[_PREV_WITHIN_SOLVE] == "t0003" + # The realized continuation anchor p2040 also links to the trunk. + assert rows[("p2040", "t0004")][_PREV_PERIOD] == "p2035" + + +def test_step_jump_fan_at_first_self_cycles_control() -> None: + """T-S control (F1 (a) byte-parity): in the fan-at-first-step shape + the branching-period copy p2035_low STILL self-cycles (its anchor IS + the first period → empty anchor range → self-cycle).""" + atl = {"p2035": P1, "p2035_low": P1, "p2040": P2, "p2040_low": P2} + pb = [("p2035", "p2035"), ("p2035", "p2035_rlz"), + ("p2035", "p2035_low"), ("p2040", "p2040"), + ("p2040", "p2040_rlz"), ("p2040", "p2040_low")] + sbtb = [("p2035_low", "low"), ("p2040_low", "low"), + ("p2035", "rlz"), ("p2040", "rlz")] + rows = _first_step_rows(make_step_jump(atl, pb, sbtb)) + # p2035_low self-cycles on its own last step. + assert rows[("p2035_low", "t0001")][_PREV_PERIOD] == "p2035_low" + # p2040_low continues from the earlier same-branch member p2035_low. + assert rows[("p2040_low", "t0004")][_PREV_PERIOD] == "p2035_low" + + +# --------------------------------------------------------------------------- +# Workdir fixtures (full cascade) + load_flextool structural tests +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def hedge_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("hedge", + db_fixture="stoch_two_period_hedge") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def hedge_ws_wf(scenario_workdir): + return scenario_workdir("hedge_ws", db_fixture="stoch_two_period_hedge") + + +@pytest.fixture(scope="module") +def hedge_storage_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("hedge_storage", + db_fixture="stoch_two_period_hedge") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def hedge_resolved(hedge_wf): + """In-process re-solve off the snapshot workdir (gives the branch + v_invest columns the committed parquet omits).""" + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(hedge_wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, sol, pb + + +# --- T-RP / T-WS / T-EEV: the hedging gate --------------------------------- + + +@pytest.mark.solver +def test_rp_objective_hand_calc(hedge_wf) -> None: + """T-RP (§8.2): the hedged mid-horizon objective is exactly 173 250.""" + obj = float( + pl.read_parquet(hedge_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(RP, rel=1e-9) + + +@pytest.mark.solver +def test_rp_invest_is_a_genuine_hedge(hedge_resolved) -> None: + """T-RP (§8.2): the shared first stage x = v_invest[base, p2035] = 60 + (a hedge between D2_low = 60 and D2_high = 120, above D1 = 40), with + recourse r_rlz = 60 at p2040 and r_low = 0 at p2040_low.""" + _data, sol, _pb = hedge_resolved + vi = sol.value("v_invest_p") + got = {r["d"]: round(float(r["value"]), 4) + for r in vi.iter_rows(named=True)} + assert got == pytest.approx({ + "p2035": 60.0, "p2040": 60.0, "p2040_low": 0.0, + }) + + +@pytest.mark.solver +def test_ws_objective_and_evpi(hedge_ws_wf, hedge_wf) -> None: + """T-WS (§8.2): the wait-and-see control is 157 500, strictly below + RP — EVPI = RP − WS = 15 750 > 0. RP > WS is the behavioural proof + that the mid-horizon first stage is genuinely SHARED (had E0 fanned + p2035 the objective would collapse to WS).""" + ws = float( + pl.read_parquet(hedge_ws_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + rp = float( + pl.read_parquet(hedge_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert ws == pytest.approx(WS, rel=1e-9) + assert rp - ws == pytest.approx(EVPI, rel=1e-9) + assert rp > ws + + +def test_eev_and_vss_hand_calc() -> None: + """T-EEV (§8.2, hand-calc + arithmetic pin): the mean-value decision + x_EV = 0.75·60 + 0.25·120 = 75; frozen and evaluated with recourse, + EEV = 2100·75 + 0.25·3150·(120−75) = 192 937.5. VSS = EEV − RP = + 19 687.5 > 0, and WS < RP < EEV strictly.""" + K1, K2 = 2100.0, 3150.0 + x_ev = 0.75 * 60 + 0.25 * 120 + assert x_ev == 75.0 + eev = K1 * x_ev + 0.25 * K2 * (120 - x_ev) + assert eev == pytest.approx(EEV) + assert EEV - RP == pytest.approx(VSS) + assert WS < RP < EEV + + +# --- T-L / T-E2: lineage populated, invest-NA by construction --------------- + + +@pytest.mark.solver +def test_lineage_shared_trunk_on_every_leaf(hedge_resolved) -> None: + """T-L (§4.1): dd_same_scenario carries (p2035, p2040_low) and its + symmetric mate — the shared first-stage invest at p2035 is on the low + scenario's information path (capacity alive on every leaf's edd walk).""" + data, _sol, _pb = hedge_resolved + dd = {(str(r["d"]), str(r["d_other"])) + for r in data.dd_same_scenario.iter_rows(named=True)} + assert ("p2035", "p2040_low") in dd + assert ("p2040_low", "p2035") in dd + + +@pytest.mark.solver +def test_d_leaf_is_a_relation_not_a_partition(hedge_resolved) -> None: + """T-C / T-L (§6.2): d_leaf is a RELATION — the shared pre-reveal + anchor p2035 is on BOTH the __realized leaf and the low leaf, so each + scenario path's total cap counts it. A strict partition would put + p2035 on __realized only (under-counting the low path).""" + data, _sol, _pb = hedge_resolved + dl = {(str(r["d"]), str(r["leaf"])) + for r in data.d_leaf.iter_rows(named=True)} + assert dl == { + ("p2035", "__realized"), ("p2040", "__realized"), + ("p2035", "low"), ("p2040_low", "low"), + } + + +@pytest.mark.solver +def test_pd_non_anticipativity_empty_no_invest_na(hedge_resolved) -> None: + """T-L / T-E2 (§7): under Option B the first stage is a single shared + trunk variable, so pd_non_anticipativity is provably EMPTY and no + non_anticipativity_invest_* constraint family is emitted (NA by + construction — no new autoscale family, CLAUDE.md invariant 1).""" + data, _sol, pb = hedge_resolved + pdna = data.pd_non_anticipativity + assert pdna is None or pdna.height == 0 + invest_na = [n for n in pb.cstr_names() + if "non_anticipativity_invest" in n] + assert invest_na == [] + + +@pytest.mark.solver +def test_per_path_cap_two_leaf_rows(hedge_resolved) -> None: + """T-C (§6.2 / §8.3): the entity total cap fans into ONE row per + scenario leaf (base × {__realized, low}) under the ``…_path`` name; + the legacy single-row ``maxInvest_entity_total`` (which would sum all + three periods) is gone.""" + _data, _sol, pb = hedge_resolved + names = pb.cstr_names() + assert "maxInvest_entity_total_path" in names + assert "maxInvest_entity_total" not in names + recs = pb.cstrs_named("maxInvest_entity_total_path") + over = recs[0].over + assert over.height == 2 + assert sorted(str(v) for v in over["leaf"].to_list()) == [ + "__realized", "low", + ] + assert {str(v) for v in over["p"].to_list()} == {"base"} + + +@pytest.mark.solver +def test_committed_output_realized_only(hedge_wf) -> None: + """§9.4: the committed v_invest output carries only the realized + (trunk) periods with real names — the shared first stage p2035 = 60 + and the realized recourse p2040 = 60; branch rows are dropped.""" + inv = pl.read_parquet( + hedge_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") + got = {r["period"]: round(float(r["base"]), 6) + for r in inv.iter_rows(named=True)} + assert got == {"p2035": 60.0, "p2040": 60.0} + + +# --- T-S (storage): NA fires + branch copy links to shared trunk ----------- + + +@pytest.mark.solver +def test_storage_na_fires_on_reveal_pair(hedge_storage_wf) -> None: + """T-S (§5.1, §9.1): with a stochastic-group storage node the dispatch + non-anticipativity net-charge pinning fires on the reveal pair + (p2040, p2040_low) — the branch's storage state is pinned to the + realized branch at the reveal window.""" + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(hedge_storage_wf) + pb = Problem() + build_flextool(pb, data) + names = pb.cstr_names() + assert "non_anticipativity_storage_use" in names + over = pb.cstrs_named("non_anticipativity_storage_use")[0].over + pairs = {(str(r["d"]), str(r["b"])) for r in over.iter_rows(named=True)} + assert pairs == {("p2040", "p2040_low")} + + +def test_storage_branch_copy_predecessor_is_shared_trunk( + hedge_storage_wf) -> None: + """T-S (§5.2): in the storage scenario, the branch copy p2040_low's + first step still links to the shared trunk p2035's last step (the + two-stage storage-state non-anticipativity), verified on the emitted + step_previous.csv.""" + sp = pl.read_csv(hedge_storage_wf / "solve_data" / "step_previous.csv") + row = sp.filter((pl.col("period") == "p2040_low") + & (pl.col("time") == "t0004")).to_dicts()[0] + assert row["previous_period"] == "p2035" + assert row["previous_within_solve"] == "t0003" + + +def test_make_step_jump_dtttdt_parity_midhorizon(hedge_wf) -> None: + """T-S (§5.2, parity guard): the CSV step_previous and the live model + lag frame ``dtttdt`` agree on d_previous / t_previous_within_solve for + every first-of-period row (the mid-horizon predecessor linkage is the + same in both derivations).""" + from flextool.engine_polars import load_flextool + + data = load_flextool(hedge_wf) + dtttdt = {(str(r["d"]), str(r["t"])): r + for r in data.dtttdt.rows(named=True)} + sp = pl.read_csv(hedge_wf / "solve_data" / "step_previous.csv") + assert sp.height == len(dtttdt) + first_steps = {("p2035", "t0001"), ("p2040", "t0004"), + ("p2040_low", "t0004")} + for r in sp.rows(named=True): + key = (r["period"], r["time"]) + m = dtttdt[key] + assert str(m["d_previous"]) == r["previous_period"], key + assert str(m["t_previous_within_solve"]) == ( + r["previous_within_solve"]), key + if key in first_steps: + continue + assert str(m["t_previous"]) == r["previous"], key diff --git a/tests/fixtures/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py index 714795b6a..bd3b5a3ed 100644 --- a/tests/fixtures/build_stoch_two_period_hedge.py +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -129,11 +129,15 @@ def build() -> dict: ["stoch", "include_stochastics + branch-varying wind + recourse"], ["rp", "stochastic_branches at p2040 (mid-horizon reveal)"], ["ws", "stochastic_branches at p2035 (wait-and-see / fan-at-first)"], + ["storage", "stochastic-group storage node (T-S: storage NA + " + "mid-horizon predecessor linkage)"], ] spec["scenarios"] = [ ["hedge", False, "Mid-horizon reveal at p2040 (RP = 173 250)"], ["hedge_ws", False, "Wait-and-see fan-at-p2035 control (WS = 157 500)"], + ["hedge_storage", False, + "Mid-horizon reveal + stochastic-group storage node (T-S)"], ] spec["scenario_alternatives"] = [ ["hedge", "init", "base"], @@ -144,12 +148,18 @@ def build() -> dict: ["hedge_ws", "base", "stoch"], ["hedge_ws", "stoch", "ws"], ["hedge_ws", "ws", None], + ["hedge_storage", "init", "base"], + ["hedge_storage", "base", "stoch"], + ["hedge_storage", "stoch", "rp"], + ["hedge_storage", "rp", "storage"], + ["hedge_storage", "storage", None], ] spec["entities"] = [ ["group", "stoch_g", None], ["model", "flextool", None], ["node", "elec", None], + ["node", "resv", None], ["profile", "wprof", None], ["solve", "stoch_2p_hedge", None], ["timeline", "y6h", None], @@ -157,15 +167,25 @@ def build() -> dict: ["timeset", "ts2", None], ["unit", "base", None], ["unit", "wind", None], + ["unit", "charge", None], + ["unit", "discharge", None], ["group__unit", ["stoch_g", "wind"], None], + ["group__node", ["stoch_g", "resv"], None], ["unit__outputNode", ["base", "elec"], None], ["unit__outputNode", ["wind", "elec"], None], + ["unit__inputNode", ["charge", "elec"], None], + ["unit__outputNode", ["charge", "resv"], None], + ["unit__inputNode", ["discharge", "resv"], None], + ["unit__outputNode", ["discharge", "elec"], None], ["unit__node__profile", ["wind", "elec", "wprof"], None], ] spec["entity_alternatives"] = [ ["node", ["elec"], "init", True], ["unit", ["base"], "init", True], ["unit", ["wind"], "init", True], + ["node", ["resv"], "storage", True], + ["unit", ["charge"], "storage", True], + ["unit", ["discharge"], "storage", True], ] # ---- init: model / solve / time structure -------------------------- @@ -271,6 +291,28 @@ def build() -> dict: # ---- ws: wait-and-see reveal at p2035 -------------------------- ["solve", "stoch_2p_hedge", "stochastic_branches", _pack(stochastic_branches_ws, "map"), "ws"], + # ---- storage: a stochastic-group storage node (T-S) ------------ + # ``resv`` is a storage node in ``stoch_g`` (via group__node), so + # under recourse mid-horizon the dispatch NA net-charge pinning + # (``non_anticipativity_storage_use``) fires on (p2040, p2040_low), + # and the storage-continuity linkage couples the branch copy's + # v_state to the shared pre-reveal trunk's end-state. It is idle + # (base+wind already meet demand) so the RP objective is unchanged; + # this scenario is structural-only. + ["node", "resv", "node_type", _pack("storage", "str"), "storage"], + ["node", "resv", "existing", _pack(1000.0, "float"), "storage"], + ["node", "resv", "storage_state_start", + _pack(0.5, "float"), "storage"], + ["node", "resv", "storage_start_end_method", + _pack("fix_start", "str"), "storage"], + ["node", "resv", "storage_solve_horizon_method", + _pack("use_reference_value", "str"), "storage"], + ["node", "resv", "storage_state_reference_value", + _pack(0.5, "float"), "storage"], + ["unit", "charge", "efficiency", _pack(1.0, "float"), "storage"], + ["unit", "charge", "existing", _pack(100.0, "float"), "storage"], + ["unit", "discharge", "efficiency", _pack(1.0, "float"), "storage"], + ["unit", "discharge", "existing", _pack(100.0, "float"), "storage"], ] return spec diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json index e261ca64f..c5a838b4d 100644 --- a/tests/fixtures/stoch_two_period_hedge.json +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -5611,6 +5611,10 @@ [ "ws", "stochastic_branches at p2035 (wait-and-see / fan-at-first)" + ], + [ + "storage", + "stochastic-group storage node (T-S: storage NA + mid-horizon predecessor linkage)" ] ], "scenarios": [ @@ -5623,6 +5627,11 @@ "hedge_ws", false, "Wait-and-see fan-at-p2035 control (WS = 157 500)" + ], + [ + "hedge_storage", + false, + "Mid-horizon reveal + stochastic-group storage node (T-S)" ] ], "scenario_alternatives": [ @@ -5665,6 +5674,31 @@ "hedge_ws", "ws", null + ], + [ + "hedge_storage", + "init", + "base" + ], + [ + "hedge_storage", + "base", + "stoch" + ], + [ + "hedge_storage", + "stoch", + "rp" + ], + [ + "hedge_storage", + "rp", + "storage" + ], + [ + "hedge_storage", + "storage", + null ] ], "entities": [ @@ -5683,6 +5717,11 @@ "elec", null ], + [ + "node", + "resv", + null + ], [ "profile", "wprof", @@ -5718,6 +5757,16 @@ "wind", null ], + [ + "unit", + "charge", + null + ], + [ + "unit", + "discharge", + null + ], [ "group__unit", [ @@ -5726,6 +5775,14 @@ ], null ], + [ + "group__node", + [ + "stoch_g", + "resv" + ], + null + ], [ "unit__outputNode", [ @@ -5742,6 +5799,38 @@ ], null ], + [ + "unit__inputNode", + [ + "charge", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "charge", + "resv" + ], + null + ], + [ + "unit__inputNode", + [ + "discharge", + "resv" + ], + null + ], + [ + "unit__outputNode", + [ + "discharge", + "elec" + ], + null + ], [ "unit__node__profile", [ @@ -5776,6 +5865,30 @@ ], "init", true + ], + [ + "node", + [ + "resv" + ], + "storage", + true + ], + [ + "unit", + [ + "charge" + ], + "storage", + true + ], + [ + "unit", + [ + "discharge" + ], + "storage", + true ] ], "parameter_values": [ @@ -6125,6 +6238,106 @@ "map" ], "ws" + ], + [ + "node", + "resv", + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ], + "storage" + ], + [ + "node", + "resv", + "existing", + [ + "MTAwMC4w", + "float" + ], + "storage" + ], + [ + "node", + "resv", + "storage_state_start", + [ + "MC41", + "float" + ], + "storage" + ], + [ + "node", + "resv", + "storage_start_end_method", + [ + "ImZpeF9zdGFydCI=", + "str" + ], + "storage" + ], + [ + "node", + "resv", + "storage_solve_horizon_method", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ], + "storage" + ], + [ + "node", + "resv", + "storage_state_reference_value", + [ + "MC41", + "float" + ], + "storage" + ], + [ + "unit", + "charge", + "efficiency", + [ + "MS4w", + "float" + ], + "storage" + ], + [ + "unit", + "charge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "storage" + ], + [ + "unit", + "discharge", + "efficiency", + [ + "MS4w", + "float" + ], + "storage" + ], + [ + "unit", + "discharge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "storage" ] ] } From 81ca8cfce5d6cf903f1b9b78e4e41351fd05d181 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 13:33:51 +0300 Subject: [PATCH 26/87] feat(stochastic): export dd_same_scenario_annuity in __all__ Completes the E1-E3 annuity-lineage addition (functions defined and consumed by _derived_npv; finish the public export). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 0fb7a5d43..539fff1b1 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1627,6 +1627,8 @@ def apply_branch_cluster( "period_branch_full_lf", "dd_same_scenario_lf", "dd_same_scenario_df", + "dd_same_scenario_annuity_lf", + "dd_same_scenario_annuity_df", "d_leaf_lf", "d_leaf_df", "pd_non_anticipativity_lf", From 55ad9cf954a1802ee3195346e6753f96ef2d18fc Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 13:53:08 +0300 Subject: [PATCH 27/87] test(stochastic): annuity-lineage dedup frame-content tests + docstring fix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 17 ++- .../test_derived_branch_lineage.py | 119 ++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 539fff1b1..f4ece119b 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1359,8 +1359,21 @@ def check_recourse_npv_preconditions( Obligation (i)'s chains are defined against SBTB (which carries the realized branch's real-named members too, so the trunk chain - is covered). A Slice E mid-horizon reveal will need to re-scope - constancy to post-reveal segments — out of scope here. + is covered). Slice E mid-horizon reveals re-scope constancy to the + POST-REVEAL segment of each chain: a strictly pre-reveal shared-trunk + anchor is a certain first-stage decision carrying weight 1.0, so the + body below (see the §5/§9.3 note by the obligation-(i) loop) excludes + such anchors from the leaf-weight-constancy chains — mixing their 1.0 + with a branch's scenario weight would be a false violation. For + fan-at-first-step / deterministic solves there are no pre-reveal + anchors, so the exclusion is a no-op and the check is byte-unchanged. + + Note: the excluded pre-reveal anchor's own weight-1.0 is STRUCTURAL — + it is a cohort-of-one in ``pd_branch_weight_lf`` (no fan siblings on + the shared trunk), so it is verified by NEITHER obligation (i) (it is + excluded from the chains) NOR obligation (ii) (a lone anchor forms no + fan cohort). This is an acceptable structural blind spot, not an + unchecked assumption. """ violations: list[str] = [] pb_rows, piu, tb_of = _lineage_inputs( diff --git a/tests/engine_polars/test_derived_branch_lineage.py b/tests/engine_polars/test_derived_branch_lineage.py index 01c977be7..73cdc5067 100644 --- a/tests/engine_polars/test_derived_branch_lineage.py +++ b/tests/engine_polars/test_derived_branch_lineage.py @@ -23,6 +23,7 @@ import pytest from flextool.engine_polars._derived_branch import ( + dd_same_scenario_annuity_df, dd_same_scenario_df, pd_non_anticipativity_df, ) @@ -496,3 +497,121 @@ def test_t12_stoch_dump_reload_roundtrip(stoch_data, stoch_wf, tmp_path): assert _rows(dd_rebuilt) == _rows(stoch_data.dd_same_scenario) assert pd_rebuilt.columns == ["d", "b"] assert _rows(pd_rebuilt) == _rows(stoch_data.pd_non_anticipativity) + + +# --------------------------------------------------------------------------- +# Annuity-lineage dedup (Slice E design §4.1 vs §8.1): the NPV ANNUITY / +# fixed-cost window frame ``dd_same_scenario_annuity`` collapses branch +# copies of the SAME calendar slot to one representative per anchor ``d``, +# so a shared pre-reveal trunk period is charged its fixed-cost annuity +# ONCE per calendar year — not once per surviving branch (the latent NPV +# over-count). These are direct, solver-free frame-content pins; the +# 173,250 objective gate covers the dedup only INDIRECTLY, so a per-frame +# regression here localises the failure (keep-diagnostic-tests). +# --------------------------------------------------------------------------- + +# Shared-trunk 2-period: reveal at p2040 — p2035 is a shared pre-reveal +# anchor (NO p2035_low branch copy); only p2040 fans to p2040_low. +PB_ST2 = [ + ("p2035", "p2035"), + ("p2040", "p2040"), + ("p2040", "p2040_rlz"), + ("p2040", "p2040_low"), +] +PIU_ST2 = ["p2035", "p2040", "p2040_low"] +SBTB_ST2 = [ + ("p2040_low", "low"), + ("p2035", "rlz"), + ("p2040", "rlz"), +] + +# Shared-trunk 3-period: reveal at p2040, low branch p2040_low + p2045_low +# (p2035 the shared pre-reveal anchor). +PB_ST3 = [ + ("p2035", "p2035"), + ("p2040", "p2040"), + ("p2045", "p2045"), + ("p2040", "p2040_low"), + ("p2045", "p2045_low"), +] +PIU_ST3 = ["p2035", "p2040", "p2045", "p2040_low", "p2045_low"] +SBTB_ST3 = [ + ("p2040_low", "low"), ("p2045_low", "low"), + ("p2035", "rlz"), ("p2040", "rlz"), ("p2045", "rlz"), +] + + +def _cap_and_annuity(tmp_path, pb=None, piu=None, sbtb=None): + """Build BOTH the capacity frame (``dd_same_scenario``) and the + annuity frame from ONE seeded provider (provider.get is + non-destructive) — the pair under comparison for the dedup pins.""" + prov = _mk_provider(pb=pb, piu=piu, sbtb=sbtb) + cap = dd_same_scenario_df(tmp_path, provider=prov) + ann = dd_same_scenario_annuity_df(tmp_path, provider=prov) + return cap, ann + + +def test_annuity_shared_trunk_2period_dedup(tmp_path): + """Shared-trunk 2-period (reveal at p2040): the trunk p2035 gets the + DEDUPED annuity forward reachability {p2035, p2040} — NOT + {p2035, p2040, p2040_low}. The branch copy p2040_low is collapsed to + its calendar anchor p2040 for the annuity frame, so the shared + first-stage capacity is charged 2 period-annuities (one per physical + period), never 3.""" + cap, ann = _cap_and_annuity(tmp_path, pb=PB_ST2, piu=PIU_ST2, + sbtb=SBTB_ST2) + assert ann.columns == ["d", "d_other"] + assert [r for r in _rows(ann) if r[0] == "p2035"] == [ + ("p2035", "p2035"), ("p2035", "p2040")] + # The capacity frame KEEPS the branch copy (the shared first-stage + # capacity must be alive in the low scenario's dispatch). + assert ("p2035", "p2040_low") in _rows(cap) + assert ("p2035", "p2040_low") not in _rows(ann) + + +def test_annuity_shared_trunk_3period_window(tmp_path): + """Shared-trunk 3-period (reveal at p2040, low branch p2040_low + + p2045_low) — the general-case fix: trunk p2035's annuity reachability + is the window-3 calendar set {p2035, p2040, p2045}, NOT the window-5 + set that would carry both branch copies p2040_low / p2045_low.""" + _, ann = _cap_and_annuity(tmp_path, pb=PB_ST3, piu=PIU_ST3, + sbtb=SBTB_ST3) + assert [r for r in _rows(ann) if r[0] == "p2035"] == [ + ("p2035", "p2035"), ("p2035", "p2040"), ("p2035", "p2045")] + + +def test_annuity_branch_window_not_collapsed(tmp_path): + """A genuine recourse window is NOT wrongly collapsed: for the branch + anchor p2040_low the annuity frame EQUALS the capacity frame on its + leaf. The real-named calendar siblings p2040 / p2045 live on the + ``__realized`` leaf, unreachable from p2040_low's branch leaf — so + there is nothing to dedup its own window against.""" + cap, ann = _cap_and_annuity(tmp_path, pb=PB_ST3, piu=PIU_ST3, + sbtb=SBTB_ST3) + cap_low = [r for r in _rows(cap) if r[0] == "p2040_low"] + ann_low = [r for r in _rows(ann) if r[0] == "p2040_low"] + assert ann_low == cap_low + assert ann_low == [ + ("p2040_low", "p2035"), + ("p2040_low", "p2040_low"), + ("p2040_low", "p2045_low")] + + +def test_annuity_noop_parity(tmp_path): + """No-op parity — the "true no-op for existing models" property: for a + fan-at-first-step shape (no shared trunk) AND a deterministic shape, + the annuity frame is BYTE-IDENTICAL to ``dd_same_scenario``. Every + leaf's members carry distinct calendar anchors, so the dedup finds + nothing to collapse.""" + # Fan-at-first-step (§4.1 fixture shape) — each branch owns its own + # first-step copy, so no anchor is a shared pre-reveal trunk. + cap, ann = _cap_and_annuity(tmp_path, pb=PB_41, piu=PIU_41, + sbtb=SBTB_41) + assert ann.equals(cap) + assert _rows(ann) == DD_41 + # Deterministic multi-period (§4.3) — single trunk leaf, all-pairs. + cap_d, ann_d = _cap_and_annuity( + tmp_path, pb=[("p2035", "p2035"), ("p2040", "p2040")], + piu=["p2035", "p2040"]) + assert ann_d.equals(cap_d) + assert _rows(ann_d) == DD_43 From 523617c88df9f1e1ebd7b0c8671cbcc42985a034 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 14:18:02 +0300 Subject: [PATCH 28/87] fix(schema): migrate test fixtures + canonical DBs to v70 The v70 stochastic_invest_method migration (Slice C) regenerated only the 2 generate_canonical recipes; the 7 tests/fixtures and 8 canonical_databases JSONs were left at v69, failing the CI test_fixtures/canonical_databases verify steps. Pure schema-additive regen (new value list + param definition + version bump); no test behavior or golden change (tests migrate JSON on load). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../howto_aggregate_output.json | 27 ++++++++++++++++++- .../howto_connections.json | 27 ++++++++++++++++++- .../canonical_databases/howto_demand.json | 27 ++++++++++++++++++- .../howto_hydro_reservoir.json | 27 ++++++++++++++++++- .../howto_hydro_reservoir_with_pump.json | 27 ++++++++++++++++++- .../howto_non_sync_and_curtailment.json | 27 ++++++++++++++++++- .../howto_ramp_and_start_up.json | 27 ++++++++++++++++++- .../templates_time_settings_only.json | 27 ++++++++++++++++++- tests/fixtures/branch2_parent_period.json | 27 ++++++++++++++++++- tests/fixtures/case14_dc_power_flow.json | 27 ++++++++++++++++++- tests/fixtures/h2_trade_parity.json | 27 ++++++++++++++++++- tests/fixtures/lh2_three_region.json | 27 ++++++++++++++++++- tests/fixtures/stochastics.json | 27 ++++++++++++++++++- tests/fixtures/stochastics_pbt_inflow.json | 27 ++++++++++++++++++- tests/fixtures/tests.json | 27 ++++++++++++++++++- 15 files changed, 390 insertions(+), 15 deletions(-) diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index 6166d7e9b..81d404be2 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -1578,7 +1578,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -2304,6 +2304,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5371,6 +5382,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index cb437d94e..c2d389370 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -1213,7 +1213,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1942,6 +1942,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5025,6 +5036,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index f55b75f2a..0685b7806 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -1129,7 +1129,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1858,6 +1858,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -4941,6 +4952,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index 11533bb9e..cac41cf5d 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -1382,7 +1382,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -2111,6 +2111,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5194,6 +5205,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index d7447bc63..7235f76d9 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -1231,7 +1231,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1960,6 +1960,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5043,6 +5054,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index e138b6eb1..ce8c3e302 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -1260,7 +1260,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1978,6 +1978,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5061,6 +5072,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index e7a94ec99..c601420e0 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -1200,7 +1200,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1929,6 +1929,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -5012,6 +5023,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index fafc4793c..e8e66e9f4 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -1079,7 +1079,7 @@ "model", "version", [ - "69", + "70", "float" ], null, @@ -1805,6 +1805,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "\"none\"", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse — shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -4879,6 +4890,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index f083519fe..832edb073 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -1271,6 +1271,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -2513,7 +2527,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -3422,6 +3436,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index 6e50f2db0..02dd7875e 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -2316,6 +2316,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -3565,7 +3579,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -4463,6 +4477,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index 08551ca95..55f17e2d6 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -7410,6 +7410,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -8659,7 +8673,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -9557,6 +9571,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "years_represented", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index dada418ed..6553a9223 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -3300,6 +3300,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -4507,7 +4521,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -5405,6 +5419,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index a4e3edbb7..529ddd115 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -1271,6 +1271,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -2513,7 +2527,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -3422,6 +3436,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index e728f54f2..d15c52bce 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -1271,6 +1271,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -2513,7 +2527,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -3422,6 +3436,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index 9f1ef79c5..11c5396c7 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -2246,6 +2246,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -3453,7 +3467,7 @@ "model", "version", [ - "Njk=", + "NzA=", "float" ], null, @@ -4351,6 +4365,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", From 9322405a69001b77cbc49a4f76cc9f234fe3ddc5 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 14:20:14 +0300 Subject: [PATCH 29/87] fix(test): skip v70 master-template test on Windows (sqlite handle lock) sync_master_template runs migrate_database, whose sqlite engine handle is still open when TemporaryDirectory cleanup runs -> WinError 32 on Windows. Same check runs on the ubuntu-only template-check CI job. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../update_flextool/test_v70_stochastic_invest_method.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/update_flextool/test_v70_stochastic_invest_method.py b/tests/update_flextool/test_v70_stochastic_invest_method.py index 75b4aad44..da77f0ad7 100644 --- a/tests/update_flextool/test_v70_stochastic_invest_method.py +++ b/tests/update_flextool/test_v70_stochastic_invest_method.py @@ -15,8 +15,10 @@ from __future__ import annotations +import sys from pathlib import Path +import pytest from spinedb_api import DatabaseMapping, from_database from flextool.update_flextool import FLEXTOOL_DB_VERSION @@ -132,6 +134,13 @@ def test_migration_reaches_v70_and_is_idempotent(tmp_path: Path) -> None: # --- M2: the committed master template is up to date -------------------- +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) def test_master_template_up_to_date() -> None: """``sync_master_template(verify_only=True)`` returns True — the spinedb_schema.json regen (with the v70 parameter) was committed. From fd76f5ec1141306edf475790cb6d76c394d02f20 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 14:24:56 +0300 Subject: [PATCH 30/87] fix(test): decouple v70 idempotency test from auto-migrated corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_migration_reaches_v70_and_is_idempotent loaded lh2_three_region.json from the test_fixtures corpus and asserted start_version < 70. But that corpus is regenerated to HEAD (>= v70) by `test_fixtures migrate-all`, so after the v70 regen it starts AT v70 and `70 < 70` fails — the two requirements are contradictory for the same file. Seed a minimal v69 DB from scratch (model + solve classes, version pinned to 69) via new `_build_v69_solve_db`, mirroring the v65/v66 migration tests, and migrate with `up_to=70`. This still exercises the v70 block from below and verifies idempotency, while staying green as the schema chain grows past 70. The v70 step already tolerates such minimal DBs (creates solve_advanced if absent). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../test_v70_stochastic_invest_method.py | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/tests/update_flextool/test_v70_stochastic_invest_method.py b/tests/update_flextool/test_v70_stochastic_invest_method.py index da77f0ad7..4445ccadc 100644 --- a/tests/update_flextool/test_v70_stochastic_invest_method.py +++ b/tests/update_flextool/test_v70_stochastic_invest_method.py @@ -19,7 +19,7 @@ from pathlib import Path import pytest -from spinedb_api import DatabaseMapping, from_database +from spinedb_api import DatabaseMapping, from_database, import_data from flextool.update_flextool import FLEXTOOL_DB_VERSION from flextool.update_flextool.db_migration import migrate_database @@ -102,23 +102,61 @@ def test_stochastic_invest_methods_value_list(tmp_path: Path) -> None: db.close() +def _build_v69_solve_db(url: str) -> None: + """Create a minimal v69 DB (``model`` + ``solve`` classes) carrying no + ``stochastic_invest_method`` — so the v70 block is the ONLY path that + can add it. + + Built from scratch (never a checked-in .sqlite, never the + ``test_fixtures`` corpus): that corpus is regenerated to HEAD (>= v70), + so loading one would start AT v70 and never exercise the v70 block. + ``model.version`` default is pinned to 69 so ``migrate_database(..., + up_to=70)`` runs only the v70 step, keeping this a faithful v70 test + even as later migrations extend the chain. The v70 step is designed + to tolerate such minimal DBs (it creates ``solve_advanced`` if absent). + """ + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 69.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v69 DB (model + solve, no invest method)") + + def test_migration_reaches_v70_and_is_idempotent(tmp_path: Path) -> None: - """A pre-v70 fixture migrates to exactly v70 (>= 70), and re-running - ``migrate_database`` is a no-op that keeps the parameter present — - the ``add_value_list_manual`` / ``add_update_item`` helpers tolerate - re-adds.""" - db_path = tmp_path / "lh2.sqlite" - url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + """A pre-v70 DB migrates to exactly v70, and re-running the v70 step + is a no-op that keeps the parameter present — the + ``add_value_list_manual`` / ``add_update_item`` helpers tolerate + re-adds. + + Seeded from scratch at v69 (see ``_build_v69_solve_db``) so it starts + genuinely BELOW v70 and exercises the v70 migration block from below, + independent of the auto-migrated ``test_fixtures`` corpus. + """ + db_path = tmp_path / "v69_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v69_solve_db(url) start_version = _db_version(url) assert start_version < 70, ( - "fixture must start below v70 so the v70 block is exercised" + "seed DB must start below v70 so the v70 block is exercised" ) - migrate_database(url) - assert _db_version(url) == FLEXTOOL_DB_VERSION >= 70 + migrate_database(url, up_to=70) + assert _db_version(url) == 70 - # Idempotent re-run — no crash, parameter still present. - migrate_database(url) + # Idempotent re-run of the v70 step — no crash, parameter still present. + migrate_database(url, up_to=70) with DatabaseMapping(url, create=False) as db: db.fetch_all() pdef = db.get_parameter_definition_item( From 05c72222feddc32e542fbbfd82a3148df47bbae2 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Fri, 25 Sep 2026 11:10:15 +0300 Subject: [PATCH 31/87] test(infra): isolate examples.sqlite readers from the shared checked-in file Several test files opened the checked-in `templates/examples.sqlite` directly. Under `-n auto --dist loadfile` those files run on DIFFERENT xdist workers, so multiple worker processes open the SAME sqlite file concurrently. On Windows, sqlite lock contention on a shared file can block indefinitely, and pytest-timeout (900s, `thread` method) cannot interrupt a native lock wait, so the job hangs until GitHub's 6h cap cancels it (observed: test_parameter_group_coverage hung ~3h48m on windows-3.12; passed on the other matrix legs). It also violates CLAUDE.md invariant #3 (tests must not read a checked-in .sqlite). Add session-scoped `examples_db_path` / `examples_db_url` fixtures in tests/conftest.py that copy the materialized template to a per-worker temp path (tmp_path_factory's basetemp is unique per xdist worker), so no two workers ever touch the same file. Session scope copies once per worker and is a valid dependency for the module-scoped consumer fixture. A copy is byte-identical, so every reader's assertions are unchanged. Refactored readers of templates/examples.sqlite: - test_parameter_group_coverage.py (the hang) -> fixture URL, with-close - spinedb_backend/test_memory_budget.py -> fixture URL - spinedb_backend/test_parsed_value_eviction.py -> fixture path in the module-scoped backend fixture - test_handoff_writers.py -> thread the isolated URL into the subprocess test_examples_e2e.py reads a different checked-in DB (projects/examples/input_sources/examples.sqlite); it now reads a per-test tmp_path copy for the same invariant-#3 reason. Already-isolated readers (materialize from JSON into tmp) left unchanged: test_export_to_tabular.py, test_xlsx_workflow.py, engine_polars/test_param_shapes_phase_a.py. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- tests/conftest.py | 44 +++++++++++++++++++ tests/model/test_examples_e2e.py | 11 ++++- tests/spinedb_backend/test_memory_budget.py | 8 +++- .../test_parsed_value_eviction.py | 10 ++++- tests/test_handoff_writers.py | 25 ++++++++--- tests/test_parameter_group_coverage.py | 19 +++----- 6 files changed, 93 insertions(+), 24 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index addd1235d..8c1371fef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -594,6 +594,50 @@ def case14_db_url(tmp_path_factory: pytest.TempPathFactory) -> str: return url +@pytest.fixture(scope="session") +def examples_db_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Session-scoped ISOLATED copy of ``templates/examples.sqlite``. + + Several test files read the checked-in ``templates/examples.sqlite`` + directly. Under ``-n auto --dist loadfile`` those files run on + DIFFERENT xdist workers, so multiple worker PROCESSES open the SAME + sqlite file concurrently. On Windows, sqlite lock contention on a + shared file can block indefinitely, and ``pytest-timeout`` (900s, + ``thread`` method) cannot interrupt a native lock wait — the job then + hangs until GitHub's 6h cap cancels it (observed: + ``test_parameter_group_coverage`` hung ~3h48m on windows-3.12). It + also violates CLAUDE.md invariant #3 (tests must not read a + checked-in ``.sqlite``). + + This fixture copies the materialized template to a per-worker temp + path so no two workers ever touch the same file. Session scope + + ``tmp_path_factory`` yields exactly one copy per xdist worker (the + factory's basetemp is made unique per worker by pytest-xdist), which + removes all cross-worker contention while copying only once per + worker; a copy is byte-identical, so every reader's assertions are + unchanged. Session scope also lets module-scoped consumer fixtures + depend on it without a ScopeMismatch. + """ + src = REPO_ROOT / "templates" / "examples.sqlite" + if not src.is_file(): + # pytest_configure normally materializes this from JSON; be + # defensive in case a caller invokes the fixture without that + # hook, or the file was removed mid-session. + from flextool.update_flextool.canonical_databases import materialize + materialize(overwrite=False) + if not src.is_file(): + pytest.skip(f"templates/examples.sqlite not available at {src}") + dest = tmp_path_factory.mktemp("examples_db_copy") / "examples.sqlite" + shutil.copy(src, dest) + return dest + + +@pytest.fixture(scope="session") +def examples_db_url(examples_db_path: Path) -> str: + """SQLite URL for the isolated :func:`examples_db_path` copy.""" + return f"sqlite:///{examples_db_path}" + + # Map scenarios.yaml ``db_fixture`` values to fixture names — kept in # conftest so adding a new fixture requires touching exactly two # places: the fixture definition above and this map. diff --git a/tests/model/test_examples_e2e.py b/tests/model/test_examples_e2e.py index b3bea2740..d22d98d90 100644 --- a/tests/model/test_examples_e2e.py +++ b/tests/model/test_examples_e2e.py @@ -20,6 +20,7 @@ """ from __future__ import annotations +import shutil from pathlib import Path import pytest @@ -53,8 +54,16 @@ def test_examples_scenario_solves(scenario: str, tmp_path: Path) -> None: if not EXAMPLES_DB.is_file(): pytest.skip(f"examples.sqlite missing: {EXAMPLES_DB}") + # Read a per-test ISOLATED copy rather than the checked-in + # ``projects/examples/input_sources/examples.sqlite`` (byte-identical, + # so the smoke contract is unchanged): never open a shared checked-in + # sqlite from a test — concurrent workers can deadlock on a read lock + # on Windows (CLAUDE.md invariant #3). + db_copy = tmp_path / "examples.sqlite" + shutil.copy(EXAMPLES_DB, db_copy) + steps = run_chain_from_db( - EXAMPLES_DB, + db_copy, scenario_name=scenario, work_folder=tmp_path, csv_dump=False, diff --git a/tests/spinedb_backend/test_memory_budget.py b/tests/spinedb_backend/test_memory_budget.py index fff1be650..669116353 100644 --- a/tests/spinedb_backend/test_memory_budget.py +++ b/tests/spinedb_backend/test_memory_budget.py @@ -83,7 +83,7 @@ def __exit__(self, *exc): self._thread.join(timeout=1.0) -def test_input_derivation_peak_rss_under_budget(tmp_path) -> None: +def test_input_derivation_peak_rss_under_budget(tmp_path, examples_db_url) -> None: """Peak RSS during ``input_derivation.run`` on examples.sqlite ``test_a_lot`` must stay under the budget. @@ -106,8 +106,12 @@ def test_input_derivation_peak_rss_under_budget(tmp_path) -> None: provider = FlexDataProvider() with _PeakWatcher() as watcher: t0 = time.perf_counter() + # ``examples_db_url`` (tests/conftest.py) is a per-worker ISOLATED + # copy of ``templates/examples.sqlite`` (byte-identical, so the + # measured budget is unchanged) — reading the shared checked-in + # file could deadlock concurrent xdist workers on a sqlite lock. input_derivation_run( - f"sqlite:///{EXAMPLES_DB}", + examples_db_url, provider, scenario_name="test_a_lot", work_folder=tmp_path, diff --git a/tests/spinedb_backend/test_parsed_value_eviction.py b/tests/spinedb_backend/test_parsed_value_eviction.py index c50eb89d0..707de6f7d 100644 --- a/tests/spinedb_backend/test_parsed_value_eviction.py +++ b/tests/spinedb_backend/test_parsed_value_eviction.py @@ -89,15 +89,21 @@ def test_mapped_item_parsed_value_contract() -> None: @pytest.fixture(scope="module") -def backend_with_index() -> SpineDBBackend: +def backend_with_index(examples_db_path) -> SpineDBBackend: """A backend whose ``_parameter_value_index`` is populated. Constructed unscoped (no scenario filter) because the SpineDBBackend's in-constructor scenario_filter path has a documented limitation around session binding that is orthogonal to Track A — the eviction logic fires regardless of scenario state. + + ``examples_db_path`` (tests/conftest.py) is a per-worker ISOLATED copy + of ``templates/examples.sqlite`` (byte-identical) rather than the + shared checked-in file, so concurrent xdist workers cannot deadlock on + a sqlite lock (CLAUDE.md invariant #3). The session-scoped fixture is + a valid dependency for this module-scoped one. """ - b = SpineDBBackend(str(EXAMPLES_DB)) + b = SpineDBBackend(str(examples_db_path)) yield b b.close() diff --git a/tests/test_handoff_writers.py b/tests/test_handoff_writers.py index 4fe1a3508..09515c23f 100644 --- a/tests/test_handoff_writers.py +++ b/tests/test_handoff_writers.py @@ -430,11 +430,20 @@ def _clean_run_dirs(repo: Path) -> None: p.unlink() -def _run_scenario(repo: Path, scenario: str, *, use_old_raw_csv: bool) -> dict[str, str]: - """Run a real scenario via subprocess and return handoff CSV contents.""" +def _run_scenario( + repo: Path, scenario: str, *, use_old_raw_csv: bool, input_db_url: str, +) -> dict[str, str]: + """Run a real scenario via subprocess and return handoff CSV contents. + + ``input_db_url`` is a per-worker ISOLATED copy of + ``templates/examples.sqlite`` (byte-identical) rather than the shared + checked-in file, so concurrent xdist workers cannot deadlock on a + sqlite read lock (CLAUDE.md invariant #3). Solver output still lands + under ``repo`` (cwd) exactly as before. + """ cmd = [ sys.executable, "run_flextool.py", - "sqlite:///templates/examples.sqlite", + input_db_url, "sqlite:///output_info.sqlite", "--scenario-name", scenario, ] @@ -477,11 +486,15 @@ def _normalise_csv(text: str, *, decimals: int = 6) -> str: "fullYear_roll", "network_all_tech", ]) -def test_handoff_csv_matches_phase3(scenario: str) -> None: +def test_handoff_csv_matches_phase3(scenario: str, examples_db_url: str) -> None: """End-to-end: handoff writer's output equals phase 3's, byte-for-byte after row-sort + 6-decimal rounding.""" - phase3 = _run_scenario(REPO_ROOT, scenario, use_old_raw_csv=True) - new = _run_scenario(REPO_ROOT, scenario, use_old_raw_csv=False) + phase3 = _run_scenario( + REPO_ROOT, scenario, use_old_raw_csv=True, input_db_url=examples_db_url, + ) + new = _run_scenario( + REPO_ROOT, scenario, use_old_raw_csv=False, input_db_url=examples_db_url, + ) for fname in phase3: assert _normalise_csv(phase3[fname]) == _normalise_csv(new[fname]), ( f"{scenario}/{fname} differs.\n" diff --git a/tests/test_parameter_group_coverage.py b/tests/test_parameter_group_coverage.py index b48e194c8..e2aebb551 100644 --- a/tests/test_parameter_group_coverage.py +++ b/tests/test_parameter_group_coverage.py @@ -9,21 +9,16 @@ from __future__ import annotations -from pathlib import Path - import pytest from spinedb_api import DatabaseMapping -FLEXTOOL_ROOT = Path(__file__).resolve().parent.parent -EXAMPLE_DB = FLEXTOOL_ROOT / "templates" / "examples.sqlite" -EXAMPLE_DB_URL = f"sqlite:///{EXAMPLE_DB}" - - -def test_every_parameter_has_a_group() -> None: - assert EXAMPLE_DB.exists(), f"Example DB not found: {EXAMPLE_DB}" - db = DatabaseMapping(EXAMPLE_DB_URL, create=False) - try: +def test_every_parameter_has_a_group(examples_db_url: str) -> None: + # ``examples_db_url`` (tests/conftest.py) is a per-worker ISOLATED copy + # of ``templates/examples.sqlite`` — never the shared checked-in file — + # so concurrent xdist workers cannot deadlock on a sqlite lock (see the + # fixture docstring and CLAUDE.md invariant #3). + with DatabaseMapping(examples_db_url, create=False) as db: db.fetch_all() orphans: list[tuple[str, str]] = [] for pdef in db.get_parameter_definition_items(): @@ -31,8 +26,6 @@ def test_every_parameter_has_a_group() -> None: group = ext.get("parameter_group_name") if not group: orphans.append((ext["entity_class_name"], pdef["name"])) - finally: - db.close() if orphans: lines = "\n".join(f" {cls}.{p}" for cls, p in sorted(orphans)) From f029ae06c9e311e083eef194a07f50b7bf925476 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 14:13:23 +0300 Subject: [PATCH 32/87] =?UTF-8?q?docs(stochastics):=20reorder=20how-to=20?= =?UTF-8?q?=E2=80=94=20basics/modes=20first,=20investment=20last,=20per-sc?= =?UTF-8?q?enario=20trimmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- docs/how_to.md | 48 +++++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/docs/how_to.md b/docs/how_to.md index 8b23ef2fd..9e04911c4 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1125,33 +1125,7 @@ Stochastics are used to represent the uncertainty of the future in the decision For the stochastics to have an effect on the results, the system needs parameters that change between the stochastic branches. These could be e.g. wind power generation or fuel prices. The model will then have separate variables in every branch for all the decision the model can take (e.g. invesment, storage state, online, flow). As a consequence, the realization phase will also be dependent on the things that happen in the stochastic branches - weighted by the probablity given to each branch. -### Per-scenario (wait-and-see) stochastic investment - -By default (`solve.stochastic_invest_method = none`) a stochastic model makes a **single** investment plan that every branch's dispatch must share — one plan serves all futures. Set - -``` -solve.stochastic_invest_method = recourse -``` - -to switch to **per-scenario (wait-and-see) investment**: every branch (the realized branch included) makes its *own* investment decisions at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are visible only when `model.output_horizon` is enabled (they are debug output — the committed results always report the realized scenario only). - -Read the results with these three points in mind: - -1. **What the objective means.** It is the *expected value of per-scenario optimal plans* — each scenario's investments are chosen knowing that scenario's data (a wait-and-see analysis / EVPI-style bound). It is **not** a single hedged plan. Use it for scenario screening and per-scenario capacity ranges. A hedged (here-and-now) plan that commits shared capacity *before* the uncertainty reveals is a planned follow-up, not what `recourse` produces today. -2. **The coupling change vs. the default.** Under `none` the single realized investment is visible to every branch's dispatch. Switching to `recourse` replaces that shared plan with independent per-branch plans, so the objective typically **drops** on the same data (each branch now gets tailor-made capacity). Comparing objectives across the flag compares two different decision models, not two answers to the same question. -3. **Cross-comparability caveat.** Turning on `recourse` also corrects the realized branch's own investment-annuity windows (the branch-period year/factor resolution rides this flag), so even the realized branch's investment costs are **not** directly A/B-comparable across the flag. Do not diff realized results across the flag and attribute the whole delta to "branch investing". - -`recourse` is incompatible with Benders decomposition (the solver rejects the combination). - -#### Hedged two-stage investment (mid-horizon reveal) - -The wait-and-see behaviour above comes from declaring every branch at the solve's **first** step, so each scenario has perfect foresight from the start. You can instead reveal the uncertainty **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). - -With this shape the pre-reveal periods stay a single real-named trunk with **one shared `v_invest`** — the genuine first-stage (here-and-now) decision. Its non-anticipativity holds by construction (there is only one variable, so nothing to tie), and its cost is counted once at full weight. The post-reveal periods fan per branch exactly as the wait-and-see case does, giving the second-stage recourse investments. The result is a real **two-stage hedge**: the shared first stage is chosen knowing only the distribution of futures, and its objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS). Only the realized trunk is committed to the output. - -The multi-period restriction of earlier versions is lifted: a branch may now start at any period boundary, and the pre-reveal horizon can be any number of periods. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. - -In this example, we show two ways to use stochastics: Single solve, rolling horizon. They all share the same test system that includes: +In this example, we show two ways to use stochastics: single solve and rolling horizon. They all share the same test system that includes: - A demand node - Coal, gas and wind power plants @@ -1220,6 +1194,26 @@ Otherwise there is no difference to the previous example. ![1Week stochastics](./img/concept/1week_stochastics.png) +### Stochastic investment + +By default (`solve.stochastic_invest_method = none`) a stochastic model makes a **single** investment plan shared by every branch — one plan for all futures. Setting + +``` +solve.stochastic_invest_method = recourse +``` + +instead lets each branch (the realized one included) invest on its own data at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are output only under `model.output_horizon` (a debug flag); committed results always report the realized scenario. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). + +When every branch starts at the solve's **first** step, `recourse` is a *wait-and-see* analysis: the objective is the expected value of per-scenario optimal plans — an EVPI-style lower bound, **not** a single hedged plan — so it typically drops relative to `none` (each branch gets tailor-made capacity) and is not directly comparable across the flag (turning on `recourse` also corrects the realized branch's own investment-annuity windows). This form is useful for scenario screening and per-scenario capacity ranges; for a single committed plan chosen before the uncertainty resolves, use the hedged mid-horizon form below. + +#### Hedged two-stage investment (mid-horizon reveal) + +The wait-and-see behaviour above comes from declaring every branch at the solve's **first** step, so each scenario has perfect foresight from the start. You can instead reveal the uncertainty **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). + +With this shape the pre-reveal periods stay a single real-named trunk with **one shared `v_invest`** — the genuine first-stage (here-and-now) decision. Its non-anticipativity holds by construction (there is only one variable, so nothing to tie), and its cost is counted once at full weight. The post-reveal periods fan per branch exactly as the wait-and-see case does, giving the second-stage recourse investments. The result is a real **two-stage hedge**: the shared first stage is chosen knowing only the distribution of futures, and its objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS). Only the realized trunk is committed to the output. + +The multi-period restriction of earlier versions is lifted: a branch may now start at any period boundary, and the pre-reveal horizon can be any number of periods. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. + ## How to use CPLEX as the solver CPLEX remains wireable as an advanced option for licensed users. Using CPLEX requires that you have installed the software, have a licence for it and have added it to PATH or to the environment where you are using the FlexTool, so that the tool can find the solver. From 90a00045c3bc0ab5014fb3f9a00e60f10d0b8545 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 14:58:26 +0300 Subject: [PATCH 33/87] Update stochastics in how_to.md --- docs/how_to.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/how_to.md b/docs/how_to.md index 9e04911c4..4b77aa489 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1139,7 +1139,7 @@ Notes about the storage options with stochastics: - The best options to use are: - - `Storage_state_start_end_method`: fix_start or fix_start_end (and the their values) + - `storage_state_start_end_method`: fix_start or fix_start_end (and the their values) - `storage_solve_horizon_method`: fix_value or fix_price (and their values) - Do not use any of the `storage_binding_methods`, they do not work correctly with stochastics (there is no unambigious end state that could circle back to the first time step). @@ -1204,11 +1204,11 @@ solve.stochastic_invest_method = recourse instead lets each branch (the realized one included) invest on its own data at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are output only under `model.output_horizon` (a debug flag); committed results always report the realized scenario. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). -When every branch starts at the solve's **first** step, `recourse` is a *wait-and-see* analysis: the objective is the expected value of per-scenario optimal plans — an EVPI-style lower bound, **not** a single hedged plan — so it typically drops relative to `none` (each branch gets tailor-made capacity) and is not directly comparable across the flag (turning on `recourse` also corrects the realized branch's own investment-annuity windows). This form is useful for scenario screening and per-scenario capacity ranges; for a single committed plan chosen before the uncertainty resolves, use the hedged mid-horizon form below. +When every branch starts at the solve's **first** step, `recourse` is a *wait-and-see* analysis: the objective is the expected value of per-scenario optimal plans weighted by their probabilities. It is **not** a single hedged plan — so it typically costs less than `none` (each branch gets tailor-made capacity) and is not directly comparable across the flag (turning on `recourse` also corrects the realized branch's own investment-annuity windows). This approach might have some very specific use cases, but the main stochastic investment mode is the hedged mid-horizon form below. #### Hedged two-stage investment (mid-horizon reveal) -The wait-and-see behaviour above comes from declaring every branch at the solve's **first** step, so each scenario has perfect foresight from the start. You can instead reveal the uncertainty **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). +In hedged two-stage investment the uncertainty is revealed **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). With this shape the pre-reveal periods stay a single real-named trunk with **one shared `v_invest`** — the genuine first-stage (here-and-now) decision. Its non-anticipativity holds by construction (there is only one variable, so nothing to tie), and its cost is counted once at full weight. The post-reveal periods fan per branch exactly as the wait-and-see case does, giving the second-stage recourse investments. The result is a real **two-stage hedge**: the shared first stage is chosen knowing only the distribution of futures, and its objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS). Only the realized trunk is committed to the output. From 1c6731279d8a20e708816fb21929f19b48642e49 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 15:51:23 +0300 Subject: [PATCH 34/87] feat(stochastic): G0 non_anticipativity_periods schema (v71) Add the solve.non_anticipativity_periods parameter (Array of periods, default unset) that decouples the operational non-anticipativity window from realized_periods (Slice G, Axis B). - v70->v71 migration (_migrate_v71_non_anticipativity_periods): Array param with default_value=None/default_type=None (no value list), grouped under solve_advanced. Adds the definition only, never a per-solve value, so every migrated solve stays on the unset -> legacy-window path (byte-identical). - PARAMETER_TYPES entry (DIMENSIONLESS) per autoscale Invariant 1. - Regenerated all four CI artifacts: spinedb_schema.json (master template), test-fixture corpus, canonical_databases JSON, canonical DBs. All four --verify steps pass; diffs are the mechanical v71 bump plus the new definition row. - v71 idempotency + definition-shape test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../autoscale/_quantity_types.py | 2 + .../howto_aggregate_output.json | 10 +- .../howto_connections.json | 10 +- .../canonical_databases/howto_demand.json | 10 +- .../howto_hydro_reservoir.json | 10 +- .../howto_hydro_reservoir_with_pump.json | 10 +- .../howto_non_sync_and_curtailment.json | 10 +- .../howto_ramp_and_start_up.json | 10 +- .../howto_stochastics.json | 10 +- .../templates_examples.json | 10 +- .../templates_time_settings_only.json | 10 +- flextool/schemas/spinedb_schema.json | 10 +- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 66 ++++++ tests/fixtures/branch2_parent_period.json | 13 +- tests/fixtures/case14_dc_power_flow.json | 13 +- tests/fixtures/h2_trade_parity.json | 13 +- tests/fixtures/lh2_three_region.json | 13 +- tests/fixtures/stochastics.json | 13 +- tests/fixtures/stochastics_pbt_inflow.json | 13 +- tests/fixtures/tests.json | 13 +- .../test_v71_non_anticipativity_periods.py | 193 ++++++++++++++++++ 22 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 tests/update_flextool/test_v71_non_anticipativity_periods.py diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 4864192ae..43765e09f 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -343,6 +343,8 @@ class QuantityType(Enum): # Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other ti... ('node_type', 'node'): QuantityType.DIMENSIONLESS, # Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' ... + ('non_anticipativity_periods', 'solve'): QuantityType.DIMENSIONLESS, + # Array of periods over which operational non-anticipativity is enforced (Slice G). Unset = legacy realized_dispatch u fix_storage window; empty = operations free from t0. ('non_synchronous_limit', 'group'): QuantityType.FRACTION, # [share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period. ('other_operational_cost', 'connection'): QuantityType.PRICE_PER_ENERGY, diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index 81d404be2..ebdc40f59 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -1578,7 +1578,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -2186,6 +2186,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index c2d389370..2383cb648 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -1213,7 +1213,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1832,6 +1832,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index 0685b7806..1f1b1956c 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -1129,7 +1129,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1748,6 +1748,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index cac41cf5d..bb17a0ff6 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -1382,7 +1382,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -2001,6 +2001,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index 7235f76d9..33d8b43c2 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -1231,7 +1231,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1850,6 +1850,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index ce8c3e302..63d886b10 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -1260,7 +1260,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1868,6 +1868,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index c601420e0..4900aa62a 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -1200,7 +1200,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1819,6 +1819,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index f089a9b80..99fbece25 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -1321,7 +1321,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1940,6 +1940,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index 6e660b5a9..9be3c8814 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -2503,7 +2503,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -3111,6 +3111,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index e8e66e9f4..fb9cda831 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -1079,7 +1079,7 @@ "model", "version", [ - "70", + "71", "float" ], null, @@ -1695,6 +1695,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index c58ac9919..b2a459d6a 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -1611,7 +1611,7 @@ [ "model", "version", - 70.0, + 71.0, null, "Contains database version information.", "model" @@ -2160,6 +2160,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + null, + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index 71a508833..d945171ac 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 70 +FLEXTOOL_DB_VERSION: int = 71 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 6b9386d23..23412e1e6 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1763,6 +1763,8 @@ def _is_yes(pv): _migrate_v69_backfill_parameter_groups(db) elif next_version == 70: _migrate_v70_stochastic_invest_method(db) + elif next_version == 71: + _migrate_v71_non_anticipativity_periods(db) else: print("Version invalid") last_completed_version = next_version @@ -3850,6 +3852,70 @@ def _migrate_v70_stochastic_invest_method(db) -> None: ) +def _migrate_v71_non_anticipativity_periods(db) -> None: + """Add the solve.non_anticipativity_periods window knob (v70 -> v71). + + Slice G decouples the operational non-anticipativity window from + ``realized_periods``. This Array-of-periods parameter selects the + (d,t) set over which the four ``non_anticipativity_*`` families + (storage/online/reserve) are pinned across stochastic branches: + + * unset (default) -> legacy ``realized_dispatch u fix_storage`` + window, byte-identical to prior behaviour; + * empty Array ``[]`` -> empty window, operations branch freely from + t0 (classic two-stage capacity expansion); + * ``[p, ...]`` -> tie only over those periods' timesteps + (intersected with steps_in_use at emit). + + Array-shaped (read by ``periods_to_tuples``, same as + ``realized_periods`` / ``fix_storage_periods``): ``default_value`` and + ``default_type`` are ``None`` and it has NO parameter_value_list. + Migrating a DB adds only the definition (no per-solve value), so every + migrated solve resolves to the unset -> legacy-window case. Grouped + under 'solve_advanced' (created if the DB lacks it). + """ + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_periods", + default_value=None, + default_type=None, + description=( + "Array of periods over which operational non-anticipativity " + "(storage / online / reserve dispatch tied across stochastic " + "branches) is enforced. Unset (default) uses the legacy window " + "= all realized-dispatch timesteps union the fix_storage " + "timesteps (byte-identical to prior behaviour). An explicitly " + "empty Array frees operations from t0 (classic two-stage " + "capacity expansion: one shared here-and-now investment plus " + "operations that branch freely per scenario). A period list " + "ties operations only over those periods' timesteps." + ), + ) + # Attach to the 'solve_advanced' parameter group, creating it if the + # DB lacks it (mirrors v70; see that migration for the get-or-create + # rationale — minimal fixtures seeded above v44 carry no groups). + if not db.get_item("parameter_group", name="solve_advanced"): + db.add_update_item( + "parameter_group", + name="solve_advanced", + color="b56f6f", + priority=87, + ) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_periods", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v71: added solve.non_anticipativity_periods (Array of periods, " + "default unset); decouples the operational non-anticipativity " + "window from realized_periods (Slice G)." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 832edb073..2383a724f 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index 02dd7875e..fb41c2684 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -3579,7 +3579,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -4334,6 +4334,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index 55f17e2d6..660af69e9 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -8673,7 +8673,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -9428,6 +9428,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index 6553a9223..393515bc3 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -4521,7 +4521,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -5276,6 +5276,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index 529ddd115..a29f3db3f 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index d15c52bce..afb269b2b 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index 11c5396c7..c0eaf47b2 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -3467,7 +3467,7 @@ "model", "version", [ - "NzA=", + "NzE=", "float" ], null, @@ -4222,6 +4222,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", diff --git a/tests/update_flextool/test_v71_non_anticipativity_periods.py b/tests/update_flextool/test_v71_non_anticipativity_periods.py new file mode 100644 index 000000000..5fc2ffab4 --- /dev/null +++ b/tests/update_flextool/test_v71_non_anticipativity_periods.py @@ -0,0 +1,193 @@ +"""Tests for the v71 database migration (Slice G). + +v71 adds ``solve.non_anticipativity_periods`` — the Array-of-periods +window knob that decouples operational non-anticipativity (storage / +online / reserve dispatch tied across stochastic branches) from +``realized_periods``: + +* unset (default) -> legacy ``realized_dispatch u fix_storage`` window, + byte-identical to prior behaviour; +* explicitly empty Array -> operations free from t0 (two-stage capacity + expansion); +* period list -> tie only over those periods' timesteps. + +The parameter is Array-shaped (``default_value``/``default_type`` both +``None``, no value list — mirrors ``realized_periods`` / +``fix_storage_periods``) and grouped under ``solve_advanced``. Migrating +a DB adds the definition only (no per-solve value), so every migrated +solve stays on the unset -> legacy-window path. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from spinedb_api import DatabaseMapping, from_database, import_data + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + + +def test_v71_version_constant_is_at_least_71() -> None: + """The engine must report a schema version >= 71 — the + non_anticipativity_periods lower bound. Later migrations keep raising + the constant, so an exact-equality assertion would regress. + """ + assert FLEXTOOL_DB_VERSION >= 71 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at the fixture's old version, which + predates v71 so the parameter can ONLY appear via the v71 block), + migrate it to HEAD, and return an open mapping.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- the migration adds the Array definition (no value list, no default) --- + + +def test_non_anticipativity_periods_definition_present(tmp_path: Path) -> None: + """``solve.non_anticipativity_periods`` exists as an unset Array param + (no default, no value list) grouped under ``solve_advanced``.""" + db = _migrated_db(tmp_path) + try: + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="non_anticipativity_periods", + ) + assert pdef, "non_anticipativity_periods not added by migration" + # Array param — no scalar default, no bound value list. + assert pdef["default_value"] is None + assert pdef["default_type"] is None + assert not pdef["parameter_value_list_name"] + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_migration_adds_no_per_solve_value(tmp_path: Path) -> None: + """The v71 migration adds the definition ONLY — never a per-solve + value — so every migrated solve resolves to the unset (legacy-window) + case and stays byte-identical.""" + db = _migrated_db(tmp_path) + try: + values = [ + v for v in db.get_parameter_value_items() + if v["parameter_definition_name"] == "non_anticipativity_periods" + ] + assert values == [], ( + "migration must not author any non_anticipativity_periods value" + ) + finally: + db.close() + + +def _build_v70_solve_db(url: str) -> None: + """Create a minimal v70 DB (``model`` + ``solve`` classes) carrying no + ``non_anticipativity_periods`` — so the v71 block is the ONLY path that + can add it. + + Built from scratch (never a checked-in .sqlite, never the + ``test_fixtures`` corpus): that corpus is regenerated to HEAD (>= v71), + so loading one would start AT v71 and never exercise the v71 block. + ``model.version`` default is pinned to 70 so ``migrate_database(..., + up_to=71)`` runs only the v71 step. The v71 step is designed to + tolerate such minimal DBs (it creates ``solve_advanced`` if absent). + """ + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 70.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v70 DB (model + solve, no NA periods)") + + +def test_migration_reaches_v71_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v71 DB migrates to exactly v71, and re-running the v71 step + is a no-op that keeps the parameter present — the ``add_update_item`` + helpers tolerate re-adds. + + Seeded from scratch at v70 (see ``_build_v70_solve_db``) so it starts + genuinely BELOW v71 and exercises the v71 migration block from below, + independent of the auto-migrated ``test_fixtures`` corpus. + """ + db_path = tmp_path / "v70_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v70_solve_db(url) + start_version = _db_version(url) + assert start_version < 71, ( + "seed DB must start below v71 so the v71 block is exercised" + ) + + migrate_database(url, up_to=71) + assert _db_version(url) == 71 + + # Idempotent re-run of the v71 step — no crash, parameter still present. + migrate_database(url, up_to=71) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="non_anticipativity_periods", + ) + assert pdef, "parameter lost on idempotent re-migration" + assert pdef["default_value"] is None + assert pdef["default_type"] is None + + +# --- the committed master template is up to date ------------------------ + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (with the v71 parameter) was committed. + This is the same assertion CI makes; pinning it locally catches a + forgotten regen.""" + assert sync_master_template(verify_only=True) is True From 546e92ff5118cec5133e899960128d6dba28c603 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 15:55:38 +0300 Subject: [PATCH 35/87] feat(stochastic): G1 configurable NA window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the resolved non_anticipativity_periods window (Slice G, Axis B) end to end, single source of truth, no new CSV. - _solve_config: tri-state reader non_anticipativity_periods_to_tristate keys off raw find_parameter_values presence (periods_to_tuples' defaultdict cannot tell unset from stored []): None=unset, []=free-ops, [(p,p),...]=curated. Stored in a plain dict so a missing key reads back None. Read after the four period dicts; propagated through duplicate_solve's dup_map_list (F3) for rolling/laddered sub-solves. - _emit_per_solve: emit_per_solve_sets drives dt_non_anticipativity_set.csv from the window (F1) — None=legacy realized u fix union (byte-identical), []=header-only, [periods]=those periods' timesteps intersected with steps_in_use (the storage family's only domain guard, F7). Threaded in from _emit_solve_time.run (runs during preprocessing, before build). - _derived_branch.dt_non_anticipativity_lf repointed to READ the authoritative set CSV; present-but-empty -> empty frame -> model's height==0 early-out skips all four families. Legacy union kept only as a defensive fallback when the CSV is absent. Byte-parity green (unchanged goldens): test_non_anticipativity, test_stochastic_continuation_fanout, test_derived_branch_lineage, non_anticipativity_storage_use_emits, recourse gate/hedge/na — all under FLEXTOOL_AUTOSCALE_STRICT=1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 46 +++++++++--- flextool/engine_polars/_emit_per_solve.py | 56 ++++++++++++--- flextool/engine_polars/_emit_solve_time.py | 10 ++- flextool/engine_polars/_solve_config.py | 82 ++++++++++++++++++++-- 4 files changed, 171 insertions(+), 23 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index f4ece119b..080a4b39f 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -544,22 +544,52 @@ def w(b: str) -> float: def dt_non_anticipativity_lf(workdir: Path | None, *, provider: "object | None" = None) -> pl.LazyFrame: - """Compute ``dt_non_anticipativity`` lazily as ``(d, t)``. + """Read ``dt_non_anticipativity`` lazily as ``(d, t)``. - Mirrors flextool's - ``preprocessing/per_solve_sets.py:267-276``:: + Slice G (F1) — the operational non-anticipativity set is now the + single source of truth materialised by + ``_emit_per_solve.emit_per_solve_sets`` as + ``solve_data/dt_non_anticipativity_set.csv``, driven by the resolved + ``non_anticipativity_periods`` window: - dt_non_anticipativity = realized_dispatch ∪ fix_storage_timesteps + * unset window -> the legacy ``realized_dispatch ∪ fix_storage`` + union (byte-identical to prior behaviour); + * empty window -> a header-only (present-but-empty) CSV -> the + model's ``if dtna.height == 0: return`` (``model.py``) skips ALL + four families -> operations free from t0; + * curated window -> those periods' timesteps ∩ ``steps_in_use``. The four ``non_anticipativity_*`` constraints fire on this set - (storage_use, online_int, online_lin, reserve). + (storage_use, online_int, online_lin, reserve). This reader sorts, + so the CSV's row-order is irrelevant to byte-parity. - Returns the empty frame (schema only) when no stochastic / chain - activity is present — which keeps the model layer's - non-anticipativity constraints disabled by default. + Defensive fallback: when the set CSV is absent from the Provider + (older snapshots / direct callers that never ran per-solve emit), + recompute the legacy ``realized_dispatch ∪ fix_storage`` union so + behaviour is unchanged. """ schema = {"d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t")} + if workdir is None: + return _empty_lf(schema) + # Authoritative set CSV (emitted every solve; may be header-only). + p = Path(workdir) / "solve_data" / "dt_non_anticipativity_set.csv" + df = _provider_get(provider, p) + if df is not None: + # Present -> the window has already been resolved into this file. + # Empty-but-present -> empty window -> operations free from t0. + if df.height == 0: + return _empty_lf(schema) + cols = df.columns + d_col = "period" if "period" in cols else cols[0] + t_col = ("step" if "step" in cols else + ("time" if "time" in cols else cols[1])) + return (df.lazy() + .select(alias_to_axis(d_col, "d"), + alias_to_axis(t_col, "t")) + .unique() + .sort("d", "t")) + # Defensive fallback — recompute the legacy union. rd = realized_dispatch_lf(workdir, provider=provider).collect() fs = fix_storage_timesteps_lf(workdir, provider=provider).collect() if rd.height == 0 and fs.height == 0: diff --git a/flextool/engine_polars/_emit_per_solve.py b/flextool/engine_polars/_emit_per_solve.py index f384de89b..f37944f12 100644 --- a/flextool/engine_polars/_emit_per_solve.py +++ b/flextool/engine_polars/_emit_per_solve.py @@ -127,12 +127,31 @@ def _project_column(df: pl.DataFrame, col_idx: int) -> list[str]: # --------------------------------------------------------------------------- -def emit_per_solve_sets(solve_data_dir: Path, *, provider) -> None: +def emit_per_solve_sets( + solve_data_dir: Path, + *, + provider, + na_window: "list | None" = None, +) -> None: """Emit ``per_solve_sets`` to the Provider. Emits the same ~24 frames under ``solve_data/`` keys via :func:`_emit` (dual-key registration). *solve_data_dir* is retained because the function still reads sister CSVs from disk via the Provider-only read helpers (which derive keys from the path). + + *na_window* is the resolved Slice-G operational non-anticipativity + window for this solve (see + ``SolveConfig.non_anticipativity_periods_to_tristate``). It drives + the ``dt_non_anticipativity_set.csv`` content — the single source of + truth read back by ``_derived_branch.dt_non_anticipativity_lf``: + + * ``None`` (unset) -> legacy ``realized_dispatch u fix_storage`` + union, byte-identical to prior behaviour; + * ``[]`` (empty) -> header-only (empty) set -> operations free + from t0 (all four non_anticipativity_* families off); + * ``[(p, p), ...]`` -> those periods' timesteps, intersected with + ``steps_in_use`` (the ONLY domain guard for the storage family, + which joins the (d,t) set on ``d`` alone — F7). """ input_dir = solve_data_dir.parent / "input" @@ -334,14 +353,35 @@ def emit_per_solve_sets(solve_data_dir: Path, *, provider) -> None: "solve_data/d_realize_dispatch_or_invest_set.csv", "period", list(union_seen.keys())) - a_df = _read_csv(solve_data_dir / "realized_dispatch.csv", - ["period", "time"], provider=provider) - b_df = _read_csv(solve_data_dir / "fix_storage_timesteps.csv", - ["period", "time"], provider=provider) + # Slice G — window-driven operational non-anticipativity set. This + # is the single source of truth: dt_non_anticipativity_lf reads it + # back. The window (na_window) tri-states the content; see the + # emit_per_solve_sets docstring. dtna_seen: dict[tuple[str, str], None] = {} - for src in (a_df, b_df): - for d, t in zip(src["period"].to_list(), src["time"].to_list()): - if d and t and (d, t) not in dtna_seen: + if na_window is None: + # Unset -> legacy realized_dispatch u fix_storage union + # (byte-identical to prior behaviour for every existing fixture). + a_df = _read_csv(solve_data_dir / "realized_dispatch.csv", + ["period", "time"], provider=provider) + b_df = _read_csv(solve_data_dir / "fix_storage_timesteps.csv", + ["period", "time"], provider=provider) + for src in (a_df, b_df): + for d, t in zip(src["period"].to_list(), src["time"].to_list()): + if d and t and (d, t) not in dtna_seen: + dtna_seen[(d, t)] = None + elif len(na_window) == 0: + # Explicit empty window -> emit header-only (operations free + # from t0); dtna_seen stays empty. + pass + else: + # Curated period list -> those periods' timesteps, intersected + # with steps_in_use (the emit-time domain guard the storage NA + # family relies on — it joins the set on ``d`` only, F7). + na_periods = {p for (p, _p_in) in na_window} + su_df = _read_csv(solve_data_dir / "steps_in_use.csv", + ["period", "time"], provider=provider) + for d, t in zip(su_df["period"].to_list(), su_df["time"].to_list()): + if d and t and d in na_periods and (d, t) not in dtna_seen: dtna_seen[(d, t)] = None _emit_tuples(provider, "solve_data/dt_non_anticipativity_set.csv", ("period", "time"), list(dtna_seen.keys())) diff --git a/flextool/engine_polars/_emit_solve_time.py b/flextool/engine_polars/_emit_solve_time.py index 3876681cd..6469e28f6 100644 --- a/flextool/engine_polars/_emit_solve_time.py +++ b/flextool/engine_polars/_emit_solve_time.py @@ -150,7 +150,15 @@ def _ck(label: str) -> None: _ck("param_in_use_sets") # ── Per-solve sets (must run BEFORE period_calculated_params) ───── - _per_solve.emit_per_solve_sets(solve_data_dir, provider=provider) + # Slice G — resolve this solve's operational non-anticipativity + # window (tri-state: None=legacy / []=free-ops / [periods]=curated) + # and drive the dt_non_anticipativity_set.csv content from it. + # ``solve_name`` here is ``complete_solve[solve]`` (the caller passes + # it), the same key the window dict is populated under. + na_window = state.solve.non_anticipativity_periods.get(solve_name) + _per_solve.emit_per_solve_sets( + solve_data_dir, provider=provider, na_window=na_window + ) _ck("per_solve_sets") # ── L1 batch 13: period_calculated_params ───────────────────────── diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 9769cf4cb..c386d3d4f 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -11,9 +11,9 @@ * The factory uses :class:`spinedb_api.DatabaseMapping` directly. * Reads ``solve``, ``model``, ``unit`` parameter classes only. Loading order matters (``make_roll_counter`` → - ``get_period_timesets`` → 4× ``periods_to_tuples``) because each - may call :meth:`duplicate_solve`, which mutates 19 sibling - defaultdicts in lockstep. + ``get_period_timesets`` → 4× ``periods_to_tuples`` → + ``non_anticipativity_periods_to_tristate``) because each may call + :meth:`duplicate_solve`, which mutates 20 sibling dicts in lockstep. * The DB schema is assumed to be v50+. v50 moved ``new_stepduration`` from ``timeset`` to ``solve``; ``update_flextool/db_migration.py`` handles upgrades for older DBs @@ -326,6 +326,14 @@ def __init__( self.realized_periods: defaultdict = defaultdict(list) self.realized_invest_periods: defaultdict = defaultdict(list) self.fix_storage_periods: defaultdict = defaultdict(list) + # Slice G (Axis B) — operational non-anticipativity window. + # TRI-STATE per solve, distinct from the period dicts above: + # absent key / None -> unset -> legacy realized u fix window; + # [] -> explicit empty -> operations free from t0; + # [(p, p), ...] -> tie only over those periods. + # A plain dict (not a defaultdict) so a missing key reads back as + # None (unset) rather than an empty list ([] = the free-ops mode). + self.non_anticipativity_periods: dict[str, list | None] = {} # Mutable tracking — populated during the recursive solve loop. self.real_solves: list[str] = [] @@ -651,7 +659,7 @@ def _opt_int(raw: dict, key: str) -> int | None: ) # Computed fields — loading order MUST be preserved exactly. - # ``duplicate_solve`` mutates 19 sibling dicts in lockstep, so + # ``duplicate_solve`` mutates 20 sibling dicts in lockstep, so # any reordering desyncs them and downstream reads silently # produce empty/zero results. obj.roll_counter = obj.make_roll_counter() @@ -668,6 +676,16 @@ def _opt_int(raw: dict, key: str) -> int | None: obj.fix_storage_periods = obj.periods_to_tuples( db=db, cl="solve", par="fix_storage_periods" ) + # Slice G — tri-state operational non-anticipativity window + # (read AFTER the four period dicts so any duplicate_solve they + # triggered has already registered the new solve names; the + # value for those duplicated solves is carried by + # ``duplicate_solve``'s dup_map_list). + obj.non_anticipativity_periods = ( + obj.non_anticipativity_periods_to_tristate( + db=db, cl="solve", par="non_anticipativity_periods" + ) + ) return obj @@ -812,9 +830,13 @@ def duplicate_solve( """Duplicate every solve-level dict entry from *old_solve* under *new_name*. - Mutates 19 sibling defaultdicts (and ``model_solve`` when + Mutates 20 sibling dicts (and ``model_solve`` when *update_model_solves* is set) so downstream readers can address - the duplicated solve transparently. + the duplicated solve transparently. One of the 20 — + ``non_anticipativity_periods`` — is a plain tri-state dict, not a + defaultdict; the ``if old_solve in dup_map.keys()`` guard copies + it the same way (an unset parent solve simply has no key to + copy, which is the correct unset default for the new name). ``update_model_solves=False`` is used by the rolling builder (Γ.8.C) where roll-named sub-solves should NOT replace their @@ -840,6 +862,7 @@ def duplicate_solve( self.realized_invest_periods, self.invest_periods, self.fix_storage_periods, + self.non_anticipativity_periods, self.decomposition, self.benders_max_iter, self.benders_tolerance, @@ -1026,6 +1049,53 @@ def periods_to_tuples( result_dict[param["entity_name"]].append((row, row)) return result_dict + def non_anticipativity_periods_to_tristate( + self, + db: "DatabaseMapping", + cl: str = "solve", + par: str = "non_anticipativity_periods", + ) -> dict[str, list | None]: + """Read ``non_anticipativity_periods`` as a TRI-STATE dict (Slice G). + + Unlike :meth:`periods_to_tuples` (which returns a + ``defaultdict(list)`` that cannot tell "no parameter row" apart + from "stored empty Array" — both collapse to ``[]``), this reader + keys off raw parameter-value presence so the byte-parity default + (unset) and the free-operations opt-in (empty Array) are distinct: + + * no ``parameter_value`` row -> ``None`` (unset -> legacy window) + * stored empty Array ``[]`` -> ``[]`` (operations free from t0) + * ``["p", ...]`` -> ``[(p, p), ...]`` (curated window) + + The tuple shape ``[(p, p), ...]`` matches + :meth:`periods_to_tuples` so the emit site can treat both readers + the same. Only real ``solve`` entities are enumerated here; + duplicated (rolling / laddered) sub-solves inherit their value via + ``duplicate_solve``'s ``dup_map_list`` (which lists this dict). + """ + entities = db.find_entities(entity_class_name=cl) + params = db.find_parameter_values( + entity_class_name=cl, + parameter_definition_name=par, + ) + # Map entity_name -> resolved value for solves that DO carry a row. + present: dict[str, list] = {} + for param in params: + param_value = api.from_database(param["value"], param["type"]) + tuples: list = [] + # An empty Array has an empty ``.values``; a populated one + # yields one (p, p) per element (mirrors periods_to_tuples). + values = getattr(param_value, "values", None) + if values: + for row in values: + tuples.append((row, row)) + present[param["entity_name"]] = tuples + result: dict[str, list | None] = {} + for entity in entities: + name = entity["name"] + result[name] = present[name] if name in present else None + return result + __all__ = [ "DictMode", From b0c466cfcc01a86f83a7ef398ed3e338aa6a377a Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 16:05:16 +0300 Subject: [PATCH 36/87] test(stochastic): G Slice-G configurable NA window gate Two-period t0-branching fixture stoch_two_period_free_ops.json (mid/up/low branches at t0001, shared investable base with stochastic_invest_method= none, stochastic-group storage node resv with branch-asymmetric wind timing -> unique per-branch storage optimum, F6). Two scenarios differ ONLY in non_anticipativity_periods. test_configurable_nonanticipativity.py (10 tests, all @solver): - free_ops (non_anticipativity_periods=[]): header-only set CSV, zero non_anticipativity_* rows, ONE shared v_invest (no branch fan), and v_state[resv] strictly divergent across branches (up>0.5, low<0.5, mid idle); objective 757_369.4. - pinned_ops (unset): set CSV = legacy 6-step horizon, storage NA fires (12 rows), v_state equal across branches (byte-parity guard); objective 1_920_800. - free_obj < pinned_obj monotonicity. conftest: stoch_two_period_free_ops_db_url session fixture + scenario_ workdir wiring (three-touchpoint pattern). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- tests/conftest.py | 26 + .../test_configurable_nonanticipativity.py | 267 + .../build_stoch_two_period_free_ops.py | 317 + tests/fixtures/stoch_two_period_free_ops.json | 6314 +++++++++++++++++ 4 files changed, 6924 insertions(+) create mode 100644 tests/engine_polars/test_configurable_nonanticipativity.py create mode 100644 tests/fixtures/build_stoch_two_period_free_ops.py create mode 100644 tests/fixtures/stoch_two_period_free_ops.json diff --git a/tests/conftest.py b/tests/conftest.py index 8c1371fef..c88df5054 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -558,6 +558,29 @@ def stoch_two_period_hedge_db_url( return url +@pytest.fixture(scope="session") +def stoch_two_period_free_ops_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_free_ops`` fixture DB — Slice G configurable + non-anticipativity window gate (design + ``specs/sliceG_configurable_nonanticipativity_design.md`` §7). + + Built from ``tests/fixtures/stoch_two_period_free_ops.json`` (generated + by ``tests/fixtures/build_stoch_two_period_free_ops.py``): two scenarios + (``free_ops`` = ``non_anticipativity_periods=[]`` -> operations free + from t0; ``pinned_ops`` = unset -> legacy window byte-parity guard) on + identical data. Defensive migration as in :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stpf") + / "stoch_two_period_free_ops.sqlite") + url = json_to_db(FIXTURES_DIR / "stoch_two_period_free_ops.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -655,6 +678,7 @@ def examples_db_url(examples_db_path: Path) -> str: "stoch_two_period_invest": "stoch_two_period_invest_db_url", "stoch_two_period_invest_na": "stoch_two_period_invest_na_db_url", "stoch_two_period_hedge": "stoch_two_period_hedge_db_url", + "stoch_two_period_free_ops": "stoch_two_period_free_ops_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -700,6 +724,7 @@ def scenario_workdir( stoch_two_period_invest_db_url, stoch_two_period_invest_na_db_url, stoch_two_period_hedge_db_url, + stoch_two_period_free_ops_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -758,6 +783,7 @@ def test_foo(scenario_workdir): "stoch_two_period_invest": stoch_two_period_invest_db_url, "stoch_two_period_invest_na": stoch_two_period_invest_na_db_url, "stoch_two_period_hedge": stoch_two_period_hedge_db_url, + "stoch_two_period_free_ops": stoch_two_period_free_ops_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/engine_polars/test_configurable_nonanticipativity.py b/tests/engine_polars/test_configurable_nonanticipativity.py new file mode 100644 index 000000000..b6e0be203 --- /dev/null +++ b/tests/engine_polars/test_configurable_nonanticipativity.py @@ -0,0 +1,267 @@ +"""Slice G — configurable operational non-anticipativity window. + +Design: ``specs/sliceG_configurable_nonanticipativity_design.md`` (§4 +byte-parity, §7 hand-checkable test). + +The new ``solve.non_anticipativity_periods`` parameter (v71) decouples the +operational-NA window (the (d,t) set over which the four +``non_anticipativity_*`` families pin storage / online / reserve dispatch +across stochastic branches) from ``realized_periods``. Tri-state: + +* unset -> legacy ``realized_dispatch ∪ fix_storage`` window + (byte-identical to prior behaviour — the ``pinned_ops`` guard); +* ``[]`` -> empty window -> operations branch freely from t0 (the + classic two-stage capacity expansion — the ``free_ops`` scenario); +* ``[p, ...]``-> tie only over those periods' timesteps. + +Fixture ``stoch_two_period_free_ops.json`` (built via +``build_stoch_two_period_free_ops.py``): two 3-step periods p2035 / p2040, +three branches mid / up / low declared at t0001 (t0-branching), a single +shared investable ``base`` unit (``stochastic_invest_method=none`` -> ONE +``v_invest``), and a stochastic-group storage node ``resv`` fed by +branch-asymmetric wind timing so each branch has a UNIQUE cost-optimal +storage trajectory (design F6). Two scenarios differ ONLY in the +``non_anticipativity_periods`` value. + +Hand-verified reference numbers (deterministic LP; see the builder): + +* ``pinned_ops`` (unset): storage NA fires (12 rows), the set CSV holds + the whole 6-step horizon, and ``v_state[resv]`` is IDENTICAL across + mid / up / low (all 0.5) — the branches cannot follow their opposite + surplus/deficit timing. Objective 1_920_800; shared v_invest 12. +* ``free_ops`` (``[]``): zero NA rows, header-only set CSV, and + ``v_state[resv]`` DIVERGES (up rises above 0.5, low falls below). + Objective 757_369.4 < pinned; shared v_invest 4.5. +""" +from __future__ import annotations + +import os + +import polars as pl +import pytest + +SOLVE = "stoch_2p_free" + +# Deterministic LP reference objectives (see the fixture builder). +PINNED_OBJ = 1_920_800.0 +FREE_OBJ = 757_369.4 + + +# --------------------------------------------------------------------------- +# Work-folder fixtures (full cascade + snapshot, AUTOSCALE_STRICT on) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def free_ops_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("free_ops", + db_fixture="stoch_two_period_free_ops") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def pinned_ops_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("pinned_ops", + db_fixture="stoch_two_period_free_ops") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +def _resolve(wf): + """In-process build+solve off the snapshot workdir; returns + ``(data, sol, pb)``.""" + from polar_high import Problem + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, sol, pb + + +@pytest.fixture(scope="module") +def free_ops_resolved(free_ops_wf): + return _resolve(free_ops_wf) + + +@pytest.fixture(scope="module") +def pinned_ops_resolved(pinned_ops_wf): + return _resolve(pinned_ops_wf) + + +def _v_state_by_branch(sol, period_prefix: str) -> dict[str, list[float]]: + """Map branch-period name -> ordered ``v_state[resv]`` trajectory for + every (d, t) whose period ``d`` starts with *period_prefix*.""" + vs = sol.value("v_state") + out: dict[str, list[tuple[str, float]]] = {} + for r in vs.iter_rows(named=True): + if str(r["n"]) != "resv": + continue + d = str(r["d"]) + if not d.startswith(period_prefix): + continue + out.setdefault(d, []).append((str(r["t"]), round(float(r["value"]), 4))) + return {d: [v for _t, v in sorted(ts)] for d, ts in out.items()} + + +# --------------------------------------------------------------------------- +# free_ops (non_anticipativity_periods = []) — operations free from t0 +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_free_ops_set_csv_is_header_only(free_ops_wf) -> None: + """§4.4.2/§7: an empty window emits a present-but-empty (header-only) + dt_non_anticipativity_set.csv.""" + p = free_ops_wf / "solve_data" / "dt_non_anticipativity_set.csv" + assert p.exists(), "set CSV must still be emitted (present-but-empty)" + assert pl.read_csv(p).height == 0 + + +@pytest.mark.solver +def test_free_ops_no_na_constraint_rows(free_ops_resolved) -> None: + """§7: an empty window -> the model's dtna.height==0 early-out skips + ALL four non_anticipativity_* families (zero rows in the built LP).""" + _data, _sol, pb = free_ops_resolved + na = [n for n in pb.cstr_names() if "non_anticipativity" in n] + assert na == [], f"expected no NA families, got {na}" + + +@pytest.mark.solver +def test_free_ops_shared_single_v_invest(free_ops_resolved) -> None: + """§7: with stochastic_invest_method=none investment stays realized-only + (R-O6) -> exactly ONE shared v_invest for the ``base`` unit (no + per-branch fan).""" + _data, sol, _pb = free_ops_resolved + vi = sol.value("v_invest_p") + keys = {str(r["d"]) for r in vi.iter_rows(named=True)} + # Only the trunk invest period p2035 — never a branch copy + # (p2035_up / p2035_low), which would signal a fanned invest axis. + assert keys == {"p2035"} + val = {str(r["d"]): round(float(r["value"]), 3) + for r in vi.iter_rows(named=True)} + assert val == pytest.approx({"p2035": 4.5}) + + +@pytest.mark.solver +def test_free_ops_v_state_diverges_across_branches(free_ops_resolved) -> None: + """§7 (F6): with operations freed each branch reaches its OWN storage + optimum, so v_state[resv] is strictly distinct across mid / up / low — + deterministic given the branch-asymmetric wind timing. ``up`` (surplus + early) charges above 0.5; ``low`` (deficit early) discharges below; + ``mid`` (flat) stays idle.""" + _data, sol, _pb = free_ops_resolved + traj = _v_state_by_branch(sol, "p2035") + assert set(traj) == {"p2035", "p2035_up", "p2035_low"} + up = traj["p2035_up"] + low = traj["p2035_low"] + mid = traj["p2035"] + # mid trunk (flat wind) keeps storage idle at the 0.5 reference. + assert mid == [0.5, 0.5, 0.5] + # up strictly above, low strictly below — at every step, and the two + # branches are pairwise distinct (a UNIQUE per-branch optimum). + assert all(u > 0.5 for u in up), up + assert all(lo < 0.5 for lo in low), low + assert up != low != mid + + +@pytest.mark.solver +def test_free_ops_objective(free_ops_wf) -> None: + """§7: the free-ops objective is the deterministic LP optimum + (per-branch dispatch under the shared capacity).""" + obj = float( + pl.read_parquet(free_ops_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(FREE_OBJ, rel=1e-6) + + +# --------------------------------------------------------------------------- +# pinned_ops (unset) — legacy window, byte-parity guard +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_pinned_ops_set_csv_is_legacy_horizon(pinned_ops_wf) -> None: + """§4.4.1: unset window -> the set CSV holds the legacy + realized_dispatch ∪ fix_storage union = the whole 6-step horizon.""" + p = pinned_ops_wf / "solve_data" / "dt_non_anticipativity_set.csv" + assert p.exists() + df = pl.read_csv(p) + assert df.height == 6 + # Exactly the two periods' three steps each. + pairs = {(str(r["period"]), str(r["time"])) for r in df.iter_rows(named=True)} + assert pairs == { + ("p2035", "t0001"), ("p2035", "t0002"), ("p2035", "t0003"), + ("p2040", "t0004"), ("p2040", "t0005"), ("p2040", "t0006"), + } + + +@pytest.mark.solver +def test_pinned_ops_storage_na_fires(pinned_ops_resolved) -> None: + """§7: unset window -> the storage NA family is present and ranges over + the whole horizon (12 rows: 6 (d,t) × the up/low sibling pairs).""" + _data, _sol, pb = pinned_ops_resolved + names = pb.cstr_names() + assert "non_anticipativity_storage_use" in names + over = pb.cstrs_named("non_anticipativity_storage_use")[0].over + assert over.height == 12 + + +@pytest.mark.solver +def test_pinned_ops_v_state_equal_across_branches(pinned_ops_resolved) -> None: + """§7 (byte-parity guard): with the legacy window the storage NA family + ties the branches' net-charge equal, so v_state[resv] is IDENTICAL + across mid / up / low on every constrained (d,t).""" + _data, sol, _pb = pinned_ops_resolved + for prefix in ("p2035", "p2040"): + traj = _v_state_by_branch(sol, prefix) + assert len(traj) == 3, traj + vals = list(traj.values()) + assert vals[0] == vals[1] == vals[2], (prefix, traj) + + +@pytest.mark.solver +def test_pinned_ops_objective(pinned_ops_wf) -> None: + obj = float( + pl.read_parquet(pinned_ops_wf / "output_raw" + / f"v_obj__{SOLVE}.parquet")["objective"][0] + ) + assert obj == pytest.approx(PINNED_OBJ, rel=1e-6) + + +# --------------------------------------------------------------------------- +# Monotonicity — freeing operations only relaxes the feasible set +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_free_ops_objective_le_pinned(free_ops_wf, pinned_ops_wf) -> None: + """§7: free_ops objective <= pinned_ops objective (non-strict — + here strict, since the pinned window forces a costly common + trajectory).""" + free = float( + pl.read_parquet(free_ops_wf / "output_raw" + / f"v_obj__{SOLVE}.parquet")["objective"][0] + ) + pinned = float( + pl.read_parquet(pinned_ops_wf / "output_raw" + / f"v_obj__{SOLVE}.parquet")["objective"][0] + ) + assert free <= pinned + assert free < pinned # the fixture provably forces strict improvement diff --git a/tests/fixtures/build_stoch_two_period_free_ops.py b/tests/fixtures/build_stoch_two_period_free_ops.py new file mode 100644 index 000000000..6de84df5c --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_free_ops.py @@ -0,0 +1,317 @@ +"""Build ``tests/fixtures/stoch_two_period_free_ops.json`` — the Slice G +configurable-non-anticipativity fixture (design +``specs/sliceG_configurable_nonanticipativity_design.md`` §7). + +Goal: a two-period, t0-branching stochastic model that exercises the +operational non-anticipativity WINDOW knob (``non_anticipativity_periods``) +on a stochastic-group storage node, with a UNIQUE per-branch storage +optimum so the "operations differ across branches" assertion is +deterministic (design §7 / F6). + +Structure +--------- +Two 3-step periods ``p2035`` (t0001-t0003) / ``p2040`` (t0004-t0006), +step duration 1 h. Three branches ``mid`` / ``up`` / ``low`` declared at +**t0001** (the first step of the first period) — a wait-and-see-from-start +(t0-branching) topology, so EVERY period is per-branch. ``mid`` is the +realized branch (weight 2); ``up`` / ``low`` carry weight 1 each +(probabilities 0.5 / 0.25 / 0.25). + +* node ``elec`` — balance, constant demand 12 MW/step. +* unit ``wind`` — free (cost 0), existing 100 MW, in ``stoch_g`` (so its + branch-varying profile is stochastic). The profile share of the 100 MW + cap is BRANCH-VARYING and asymmetric in TIMING, engineered so each + branch's cost-optimal storage trajectory is strictly distinct: + - ``up`` — surplus early (steps 1-3 share 0.20 -> wind 20, +8 over + demand), deficit late (steps 4-6 share 0.00 -> deficit 12). + - ``low`` — the mirror: deficit early, surplus late. + - ``mid`` — flat 0.10 -> wind 10, a steady 2 MW deficit every step. + Total wind per branch = 60 MWh < demand 72 MWh, so a net 12 MWh must + come from the investable ``base`` unit in EVERY branch (a shared, + deterministic capacity floor). +* node ``resv`` — storage in ``stoch_g`` (via ``group__node``), existing + 1000 MWh, start fixed at 0.5 (500 MWh), reference end 0.5. NO inflow + (deterministic) — so the ONLY driver of the storage state is the + controllable charge/discharge, which is exactly what the storage NA + family pins. ``charge`` (elec->resv) and ``discharge`` (resv->elec) are + free existing 100 MW units; a tiny 0.01 CUR/MWh discharge cost breaks + degenerate cycling so the per-branch optimum is unique. +* unit ``base`` — investable peaker -> elec, ``stochastic_invest_method = + none`` so there is exactly ONE shared ``v_invest`` (R-O6: invest stays + realized-only, never fans to the branch copies). ``other_operational_ + cost`` 5 CUR/MWh so wind + stored energy is preferred to fresh base. + +Behaviour under the two scenarios (identical data, differing ONLY in the +``non_anticipativity_periods`` value): + +* ``pinned_ops`` — ``non_anticipativity_periods`` UNSET -> legacy window + = realized_dispatch u fix_storage = the whole horizon. The storage NA + family ties the branches' charge/discharge net-charge equal at every + step, so with no branch inflow the ``v_state[resv]`` trajectory is + IDENTICAL across ``mid`` / ``up`` / ``low`` — the branches cannot follow + their opposite surplus/deficit timing and pay for it. (Byte-parity + guard: this is the flag-off behaviour.) +* ``free_ops`` — ``non_anticipativity_periods = []`` -> empty window -> + ZERO ``non_anticipativity_*`` rows -> each branch charges/discharges to + its own optimum, so ``v_state[resv]`` DIVERGES across branches, and the + objective is <= the pinned objective (freeing operations only relaxes + the feasible set). + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_two_period_free_ops.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 7)] +P1_STEPS = STEPS[:3] +P2_STEPS = STEPS[3:] + + +def _pack(value: object, value_type: object) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + src = json.loads((FIXTURES / "stochastics.json").read_text()) + + spec: dict = { + key: src[key] + for key in ( + "entity_classes", + "parameter_value_lists", + "parameter_groups", + "parameter_definitions", + "parameter_types", + ) + } + + # ``stochastics.json`` carries the pre-v70 schema; inject the v70 + # ``solve.stochastic_invest_method`` opt-in and the v71 + # ``solve.non_anticipativity_periods`` window knob before import + # (json_to_db validates against the embedded schema BEFORE the + # idempotent v70/v71 migrations reconcile it). + if not any(vl[0] == "stochastic_invest_methods" + for vl in spec["parameter_value_lists"]): + spec["parameter_value_lists"] += [ + ["stochastic_invest_methods", _pack("none", "str")], + ["stochastic_invest_methods", _pack("recourse", "str")], + ] + if not any(pd[0] == "solve" and pd[1] == "stochastic_invest_method" + for pd in spec["parameter_definitions"]): + spec["parameter_definitions"].append([ + "solve", "stochastic_invest_method", + _pack("none", "str"), "stochastic_invest_methods", + "How stochastic branches participate in investment " + "(none/recourse).", "solve_advanced", + ]) + if not any(pd[0] == "solve" and pd[1] == "non_anticipativity_periods" + for pd in spec["parameter_definitions"]): + # Array param (default null, no value list) — mirrors + # realized_periods / fix_storage_periods (v71, Slice G). + spec["parameter_definitions"].append([ + "solve", "non_anticipativity_periods", + _pack(None, None), None, + "Array of periods over which operational non-anticipativity " + "is enforced (unset = legacy window; [] = free ops).", + "solve_advanced", + ]) + + spec["alternatives"] = [ + ["init", "Model, solve and time structure"], + ["base", "System data (elec demand + wind + investable base)"], + ["stoch", "include_stochastics + branch-varying wind + t0 branches " + "+ stochastic-group storage (stochastic_invest_method=none)"], + ["freeops", "non_anticipativity_periods = [] (operations free " + "from t0)"], + ] + spec["scenarios"] = [ + ["free_ops", False, + "non_anticipativity_periods = [] -> operations branch freely; " + "shared invest, divergent v_state"], + ["pinned_ops", False, + "non_anticipativity_periods UNSET -> legacy window; storage NA " + "pins v_state equal across branches (byte-parity guard)"], + ] + spec["scenario_alternatives"] = [ + ["free_ops", "init", "base"], + ["free_ops", "base", "stoch"], + ["free_ops", "stoch", "freeops"], + ["free_ops", "freeops", None], + ["pinned_ops", "init", "base"], + ["pinned_ops", "base", "stoch"], + ["pinned_ops", "stoch", None], + ] + + spec["entities"] = [ + ["group", "stoch_g", None], + ["model", "flextool", None], + ["node", "elec", None], + ["node", "resv", None], + ["profile", "wprof", None], + ["solve", "stoch_2p_free", None], + ["timeline", "y6h", None], + ["timeset", "ts1", None], + ["timeset", "ts2", None], + ["unit", "base", None], + ["unit", "wind", None], + ["unit", "charge", None], + ["unit", "discharge", None], + ["group__unit", ["stoch_g", "wind"], None], + ["group__node", ["stoch_g", "resv"], None], + ["unit__outputNode", ["base", "elec"], None], + ["unit__outputNode", ["wind", "elec"], None], + ["unit__inputNode", ["charge", "elec"], None], + ["unit__outputNode", ["charge", "resv"], None], + ["unit__inputNode", ["discharge", "resv"], None], + ["unit__outputNode", ["discharge", "elec"], None], + ["unit__node__profile", ["wind", "elec", "wprof"], None], + ] + spec["entity_alternatives"] = [ + ["node", ["elec"], "base", True], + ["unit", ["base"], "base", True], + ["unit", ["wind"], "base", True], + ["node", ["resv"], "stoch", True], + ["unit", ["charge"], "stoch", True], + ["unit", ["discharge"], "stoch", True], + ] + + # ---- init: model / solve / time structure -------------------------- + # t0 branching: all three branches declared at p2035 / t0001. ``mid`` + # is realized (yes, weight 2); ``up`` / ``low`` weight 1 each. + stochastic_branches = _map([ + ("p2035", _nested([ + ("mid", _nested([("t0001", _nested([("yes", 2.0)]))])), + ("up", _nested([("t0001", _nested([("no", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 1.0)]))])), + ])), + ]) + + # Deterministic profile (base alternative) — matches the realized + # ``mid`` branch: flat 0.10 (wind 10, a steady 2 MW deficit / step). + profile_base = _map([(t, 0.10) for t in STEPS]) + + # Branch-varying wind profile (stoch alternative). Keyed + # branch -> period-first-step -> per-step share of the 100 MW cap. + # t0-branching, so BOTH periods are per-branch — declare shares for + # every period's first step. ``up`` = surplus early / deficit late; + # ``low`` = the mirror; ``mid`` = flat (byte-parity trunk). + def _branch_profile(early: float, late: float) -> dict: + return _nested([ + ("t0001", _nested([(t, early) for t in P1_STEPS])), + ("t0004", _nested([(t, late) for t in P2_STEPS])), + ]) + + profile_stoch = _map([ + ("mid", _branch_profile(0.10, 0.10)), + ("up", _branch_profile(0.20, 0.00)), + ("low", _branch_profile(0.00, 0.20)), + ]) + + # Deterministic elec demand: 12 MW at every step. + inflow = _map([(t, -12.0) for t in STEPS]) + + spec["parameter_values"] = [ + ["model", "flextool", "solves", + _pack({"value_type": "str", "data": ["stoch_2p_free"]}, "array"), + "init"], + ["timeline", "y6h", "timestep_duration", + _pack(_map([(t, 1.0) for t in STEPS]), "map"), "init"], + ["timeset", "ts1", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts1", "timeset_duration", + _pack(_map([(t, 1.0) for t in P1_STEPS]), "map"), "init"], + ["timeset", "ts2", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts2", "timeset_duration", + _pack(_map([(t, 1.0) for t in P2_STEPS]), "map"), "init"], + ["solve", "stoch_2p_free", "period_timeset", + _pack(_map([("p2035", "ts1"), ("p2040", "ts2")]), "map"), "init"], + ["solve", "stoch_2p_free", "realized_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_free", "invest_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), + "init"], + ["solve", "stoch_2p_free", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_2p_free", "years_represented", + _pack(_map([("p2035", 1.0), ("p2040", 1.0)]), "map"), "init"], + # ---- base: system data ---------------------------------------- + ["node", "elec", "node_type", _pack("balance", "str"), "base"], + ["node", "elec", "penalty_up", _pack(10000.0, "float"), "base"], + ["node", "elec", "penalty_down", _pack(10000.0, "float"), "base"], + ["node", "elec", "inflow", _pack(inflow, "map"), "base"], + ["unit", "wind", "existing", _pack(100.0, "float"), "base"], + ["unit", "wind", "efficiency", _pack(1.0, "float"), "base"], + ["unit__node__profile", ["wind", "elec", "wprof"], "profile_method", + _pack("upper_limit", "str"), "base"], + ["profile", "wprof", "profile", _pack(profile_base, "map"), "base"], + ["unit", "base", "efficiency", _pack(1.0, "float"), "base"], + ["unit", "base", "existing", _pack(0.0, "float"), "base"], + ["unit", "base", "virtual_unitsize", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_method", + _pack("invest_total", "str"), "base"], + ["unit", "base", "invest_cost", _pack(20.0, "float"), "base"], + ["unit", "base", "discount_rate", _pack(0.05, "float"), "base"], + ["unit", "base", "lifetime", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_max_total", _pack(150.0, "float"), + "base"], + ["unit__outputNode", ["base", "elec"], "other_operational_cost", + _pack(5.0, "float"), "base"], + # ---- stoch: branches + branch-varying profile + storage -------- + ["group", "stoch_g", "include_stochastics", _pack("yes", "str"), + "stoch"], + ["profile", "wprof", "profile", _pack(profile_stoch, "map"), + "stoch"], + ["solve", "stoch_2p_free", "stochastic_branches", + _pack(stochastic_branches, "map"), "stoch"], + ["solve", "stoch_2p_free", "stochastic_invest_method", + _pack("none", "str"), "stoch"], + ["node", "resv", "node_type", _pack("storage", "str"), "stoch"], + ["node", "resv", "existing", _pack(1000.0, "float"), "stoch"], + ["node", "resv", "storage_state_start", + _pack(0.5, "float"), "stoch"], + ["node", "resv", "storage_start_end_method", + _pack("fix_start", "str"), "stoch"], + ["node", "resv", "storage_solve_horizon_method", + _pack("use_reference_value", "str"), "stoch"], + ["node", "resv", "storage_state_reference_value", + _pack(0.5, "float"), "stoch"], + ["unit", "charge", "efficiency", _pack(1.0, "float"), "stoch"], + ["unit", "charge", "existing", _pack(100.0, "float"), "stoch"], + ["unit", "discharge", "efficiency", _pack(1.0, "float"), "stoch"], + ["unit", "discharge", "existing", _pack(100.0, "float"), "stoch"], + ["unit__outputNode", ["discharge", "elec"], "other_operational_cost", + _pack(0.01, "float"), "stoch"], + # ---- freeops: empty NA window (operations free from t0) -------- + ["solve", "stoch_2p_free", "non_anticipativity_periods", + _pack({"value_type": "str", "data": []}, "array"), "freeops"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_free_ops.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_free_ops.json b/tests/fixtures/stoch_two_period_free_ops.json new file mode 100644 index 000000000..f67d82d9a --- /dev/null +++ b/tests/fixtures/stoch_two_period_free_ops.json @@ -0,0 +1,6314 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzE=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solve and time structure" + ], + [ + "base", + "System data (elec demand + wind + investable base)" + ], + [ + "stoch", + "include_stochastics + branch-varying wind + t0 branches + stochastic-group storage (stochastic_invest_method=none)" + ], + [ + "freeops", + "non_anticipativity_periods = [] (operations free from t0)" + ] + ], + "scenarios": [ + [ + "free_ops", + false, + "non_anticipativity_periods = [] -> operations branch freely; shared invest, divergent v_state" + ], + [ + "pinned_ops", + false, + "non_anticipativity_periods UNSET -> legacy window; storage NA pins v_state equal across branches (byte-parity guard)" + ] + ], + "scenario_alternatives": [ + [ + "free_ops", + "init", + "base" + ], + [ + "free_ops", + "base", + "stoch" + ], + [ + "free_ops", + "stoch", + "freeops" + ], + [ + "free_ops", + "freeops", + null + ], + [ + "pinned_ops", + "init", + "base" + ], + [ + "pinned_ops", + "base", + "stoch" + ], + [ + "pinned_ops", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "node", + "resv", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_free", + null + ], + [ + "timeline", + "y6h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "timeset", + "ts2", + null + ], + [ + "unit", + "base", + null + ], + [ + "unit", + "wind", + null + ], + [ + "unit", + "charge", + null + ], + [ + "unit", + "discharge", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "group__node", + [ + "stoch_g", + "resv" + ], + null + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__inputNode", + [ + "charge", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "charge", + "resv" + ], + null + ], + [ + "unit__inputNode", + [ + "discharge", + "resv" + ], + null + ], + [ + "unit__outputNode", + [ + "discharge", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "base", + true + ], + [ + "unit", + [ + "base" + ], + "base", + true + ], + [ + "unit", + [ + "wind" + ], + "base", + true + ], + [ + "node", + [ + "resv" + ], + "stoch", + true + ], + [ + "unit", + [ + "charge" + ], + "stoch", + true + ], + [ + "unit", + [ + "discharge" + ], + "stoch", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF8ycF9mcmVlIl19", + "array" + ], + "init" + ], + [ + "timeline", + "y6h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl0sIFsicDIwNDAiLCAidHMyIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMS4wXV19", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTIuMF0sIFsidDAwMDIiLCAtMTIuMF0sIFsidDAwMDMiLCAtMTIuMF0sIFsidDAwMDQiLCAtMTIuMF0sIFsidDAwMDUiLCAtMTIuMF0sIFsidDAwMDYiLCAtMTIuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjFdLCBbInQwMDAyIiwgMC4xXSwgWyJ0MDAwMyIsIDAuMV0sIFsidDAwMDQiLCAwLjFdLCBbInQwMDA1IiwgMC4xXSwgWyJ0MDAwNiIsIDAuMV1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "base", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "base", + "invest_cost", + [ + "MjAuMA==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + "other_operational_cost", + [ + "NS4w", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibWlkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuMV0sIFsidDAwMDIiLCAwLjFdLCBbInQwMDAzIiwgMC4xXV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4xXSwgWyJ0MDAwNSIsIDAuMV0sIFsidDAwMDYiLCAwLjFdXX1dXX1dLCBbInVwIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuMl0sIFsidDAwMDIiLCAwLjJdLCBbInQwMDAzIiwgMC4yXV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4wXSwgWyJ0MDAwNSIsIDAuMF0sIFsidDAwMDYiLCAwLjBdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjBdLCBbInQwMDAyIiwgMC4wXSwgWyJ0MDAwMyIsIDAuMF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMl0sIFsidDAwMDUiLCAwLjJdLCBbInQwMDA2IiwgMC4yXV19XV19XV19", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDIuMF1dfV1dfV0sIFsidXAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XV19XV19", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "existing", + [ + "MTAwMC4w", + "float" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_state_start", + [ + "MC41", + "float" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_start_end_method", + [ + "ImZpeF9zdGFydCI=", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_solve_horizon_method", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_state_reference_value", + [ + "MC41", + "float" + ], + "stoch" + ], + [ + "unit", + "charge", + "efficiency", + [ + "MS4w", + "float" + ], + "stoch" + ], + [ + "unit", + "charge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "stoch" + ], + [ + "unit", + "discharge", + "efficiency", + [ + "MS4w", + "float" + ], + "stoch" + ], + [ + "unit", + "discharge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "stoch" + ], + [ + "unit__outputNode", + [ + "discharge", + "elec" + ], + "other_operational_cost", + [ + "MC4wMQ==", + "float" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "non_anticipativity_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogW119", + "array" + ], + "freeops" + ] + ] +} From d381ce1767f0ed7609ca08ae0222e5e4bd4f6fdc Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 16:09:40 +0300 Subject: [PATCH 37/87] docs(how-to): G3 non_anticipativity_periods window guidance Document the new solve.non_anticipativity_periods knob in the multi-period stochastics section: unset = legacy pinned-over-horizon window; empty array = free operations from t0 (two-stage capacity expansion with a shared invest); period list = curated window (e.g. mid-horizon reveal). Notes that a curated list replaces the default window and that realized_periods still governs the dispatch output. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- docs/how_to.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/how_to.md b/docs/how_to.md index 4b77aa489..5b775d93b 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1174,6 +1174,7 @@ Notes for multi-period stochastic solves: - If `years_represented` is defined for the solve, it must cover **all** of the solve's periods. A configuration that defines it for only some periods fails with a configuration error (older versions silently ran on a truncated horizon instead). - The realized branch's input weight is always treated as 1.0 along the whole realized chain — the sibling branches' weights are normalized against 1.0, not against a DB value on the realized row. - In a single solve whose `realized_periods` span the whole horizon, the non-anticipatory constraints cover the whole horizon: the shared decision types (storage usage, online status, reserve) stay pinned to the realized branch through the later periods, and post-branching divergence shows up only in the flow variables. Use a rolling solve when the branches should be free to commit differently after the information is revealed — each roll then realizes only its jump slice and the non-anticipatory window ends at the reveal. +- To free the operational branches within a *single* solve (without moving to a rolling solve), set the `solve` array parameter `non_anticipativity_periods`. Left unset (the default) it keeps the behaviour above — the operational non-anticipativity window equals the realized-dispatch timesteps (plus any `fix_storage` timesteps), so operations stay pinned across the whole realized horizon. Set it to an **empty array** to free operations from the first timestep: the shared decision types then branch freely per scenario while the investment plan is still a single shared decision (`stochastic_invest_method = none`) — this is the classic two-stage capacity-expansion structure (one here-and-now investment, wait-and-see operations). Set it to a **list of periods** to pin operations only over those periods (e.g. a mid-horizon reveal: pin the pre-reveal periods, free the rest); the listed periods' timesteps are intersected with the solve's active timeline, and a curated list *replaces* (does not add to) the default realized-dispatch window. The dispatch output window is unaffected — `realized_periods` still governs which periods are reported. - A `realized: yes` row placed at a later period's first timestep does not re-branch the model, but it selects which forecast branch's timeseries the realized chain uses from that period onwards. This can be used deliberately for per-period forecast-series selection; if you did not intend a series swap, keep the `realized: yes` rows only at the solve (or roll) start. ### Rolling horizon stochastics From ef4f2989c948e70815080ac939f7b9fc1bc4327b Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 16:29:56 +0300 Subject: [PATCH 38/87] fix(export): whitelist non_anticipativity_periods in split_params The v71 solve param was missing from export_settings.yaml, failing test_no_schema_params_missing_from_yaml. Added alongside the sibling period-array params (realized_periods etc.). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/export_to_tabular/export_settings.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 47424fd11..222a6088c 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -88,7 +88,8 @@ split_params: "stochastic_invest_method"] solve_period: params: ["period_timeset", "years_represented", "realized_periods", - "invest_periods", "realized_invest_periods", "fix_storage_periods"] + "invest_periods", "realized_invest_periods", "fix_storage_periods", + "non_anticipativity_periods"] rolling_parameters: params: ["rolling_solve_jump", "rolling_solve_horizon", "rolling_duration"] solve_s_stochastic_branches: @@ -295,6 +296,7 @@ data_type_overrides: realized_invest_periods: "string (2d-map)" realized_periods: "string (2d-map)" fix_storage_periods: "string (2d-map)" + non_anticipativity_periods: "string (2d-map)" # Parameters that are ONLY period-indexed (never time-indexed). # Used to filter them out of _t sheets. Derived from parameter descriptions. From 2c1aa76c5102c006f1d8d8b21dc3d1da30960ed1 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 16:32:07 +0300 Subject: [PATCH 39/87] =?UTF-8?q?fix(stochastic):=20G=20tri-state=20reader?= =?UTF-8?q?=20=E2=80=94=20malformed=20value=20->=20legacy,=20doc=20config-?= =?UTF-8?q?load-fan=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups (non-blocking): a non-Array value now resolves to the legacy window instead of silently freeing operations (G-2); docstring now states explicit windows do not propagate to config-load-fanned 2D-Map-laddered solves (G-1). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_solve_config.py | 30 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index c386d3d4f..7bb40f453 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -1069,9 +1069,21 @@ def non_anticipativity_periods_to_tristate( The tuple shape ``[(p, p), ...]`` matches :meth:`periods_to_tuples` so the emit site can treat both readers - the same. Only real ``solve`` entities are enumerated here; - duplicated (rolling / laddered) sub-solves inherit their value via + the same. Only real ``solve`` entities are enumerated here. + Solve-time duplicated sub-solves (rolling / laddered dispatch, + created after config load) inherit their value via ``duplicate_solve``'s ``dup_map_list`` (which lists this dict). + Note: solves fanned at *config-load* time from a 2D-Map + ``period_timeset`` / ``invest_periods`` do NOT inherit an explicit + window here — this reader runs after those fans and enumerates only + the original solve entities, so such children resolve to the legacy + default (consistent with the sibling period dicts, which also do not + propagate to config-load-fanned children). Explicit windows on + 2D-Map-laddered solves are therefore not supported in v1. + + A malformed value that is not an Array (e.g. a stray scalar) is + treated as unset (legacy window), never as the behaviour-changing + empty-Array free-operations opt-in. """ entities = db.find_entities(entity_class_name=cl) params = db.find_parameter_values( @@ -1082,13 +1094,15 @@ def non_anticipativity_periods_to_tristate( present: dict[str, list] = {} for param in params: param_value = api.from_database(param["value"], param["type"]) - tuples: list = [] - # An empty Array has an empty ``.values``; a populated one - # yields one (p, p) per element (mirrors periods_to_tuples). + # An empty Array has an empty ``.values`` ([]); a populated one + # yields one (p, p) per element (mirrors periods_to_tuples). A + # value that is not an Array has no ``.values`` (None) — malformed + # authoring; skip it so it resolves to the legacy window rather + # than silently freeing operations from t0. values = getattr(param_value, "values", None) - if values: - for row in values: - tuples.append((row, row)) + if values is None: + continue + tuples: list = [(row, row) for row in values] present[param["entity_name"]] = tuples result: dict[str, list | None] = {} for entity in entities: From 69f53eaf2ebf47839e2bc3a8e551ac1ebdec8124 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 16:36:57 +0300 Subject: [PATCH 40/87] fix(schema): stochastic_branches leaf is numeric, not Utf8 (axis contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter_value_dtypes override marked solve.stochastic_branches as Utf8 alongside the period-name params, but its map leaf is the branch WEIGHT (a number). The comment says overrides list only genuinely non-numeric leaves; removing it lets the weight use the Float64 default. Dormant today (the live reader path returns floats via find_parameter_ values), but corrects the classification for any future SpineDbReader consumer. No data migration — DB weights are already numeric. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/schemas/flextool_axis_contract.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flextool/schemas/flextool_axis_contract.json b/flextool/schemas/flextool_axis_contract.json index 9be5cdb33..cc0875349 100644 --- a/flextool/schemas/flextool_axis_contract.json +++ b/flextool/schemas/flextool_axis_contract.json @@ -218,8 +218,7 @@ "solve.realized_invest_periods": "Utf8", "solve.realized_periods": "Utf8", "solve.fix_storage_periods": "Utf8", - "solve.period_timeset": "Utf8", - "solve.stochastic_branches": "Utf8" + "solve.period_timeset": "Utf8" } }, "_review_notes": [ From cb927ba2275bb4e7823f6b4d515b355ca35f783b Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 17:29:46 +0300 Subject: [PATCH 41/87] docs(stochastics): explain default dispatch vs investment NA assumptions + overrides Adds an upfront subsection stating what a dispatch solve and an investment solve tie across branches by default (storage/online/reserve over the realized window; flows free; single shared investment) and how to override each (stochastic_invest_method, non_anticipativity_periods). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- docs/how_to.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/how_to.md b/docs/how_to.md index 5b775d93b..d1806e2fe 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1146,6 +1146,26 @@ Notes about the storage options with stochastics: ![Stocahstic system](./img/concept/stochastic_system.png) +### What the model ties across branches (dispatch vs. investment defaults) + +Wherever the branches share the same information — the *realized* (committed) part of the horizon, before a forecast is revealed — the model **ties** some decisions across all branches to the realized branch and lets the rest **diverge**. What is tied depends on whether the solve invests. + +**Any stochastic solve (dispatch or investment) assumes, by default:** + +- **Storage state, unit commitment (online status) and reserves are tied** across the branches over the solve's realized/committed periods — the branches must take the same storage and start-up decisions while they still share information. In a **single** solve whose realized periods span the whole horizon this pins them over the whole horizon (so only the flows differ between branches); in a **rolling** solve the tie covers only each roll's committed (jump) slice, and the branches operate freely in the forecast horizon beyond the reveal. +- **Energy flows are always free** — each scenario dispatches its flows independently. + +**An investment solve additionally assumes, by default:** + +- **A single, shared here-and-now investment plan** for all branches (`stochastic_invest_method = none`) — you commit one set of investments regardless of which future occurs. + +So a plain **dispatch** solve = commit the realized part (shared storage / online / reserve), forecast the rest, flows free. An **investment** solve = the same operational tie **plus** one shared investment plan. + +**Overriding the defaults:** + +- *Investment* — set `stochastic_invest_method = recourse` for per-scenario (wait-and-see) investment, or declare a mid-horizon reveal for a genuine two-stage hedge (see *Stochastic investment* below). +- *Operations* — set the `solve` array parameter `non_anticipativity_periods` to change the operational tie window: an **empty array** frees operations from the first timestep (each scenario operates freely — combined with a single shared investment this is the classic two-stage capacity-expansion model), or a **list of periods** ties operations only over those periods. See *Stochastics over multiple periods* for the details. + ### Single solve stochastics **(stochastics.sqlite scenario: 2_day_stochastic_dispatch)** From 75afb58f0711b4319c0579dbcbd80fbddafe7969 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 17:47:27 +0300 Subject: [PATCH 42/87] =?UTF-8?q?docs(stochastics):=20reframe=20investment?= =?UTF-8?q?=20modes=20=E2=80=94=20standard=20two-stage=20is=20the=20main?= =?UTF-8?q?=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main stochastic-investment mode is a single shared here-and-now investment with operations free per scenario from t0 (none + non_anticipativity_periods=[]), not the mid-horizon hedge. The hedge is recast honestly as the variant that shares NEAR-TERM OPERATIONS too (first periods fully deterministic). Notes the multi-period investment non-anticipativity window (shared-first-period / recourse-later) as a planned addition. Wait-and-see framed as a screening tool. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- docs/how_to.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/how_to.md b/docs/how_to.md index d1806e2fe..42efeb7b8 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1217,23 +1217,25 @@ Otherwise there is no difference to the previous example. ### Stochastic investment -By default (`solve.stochastic_invest_method = none`) a stochastic model makes a **single** investment plan shared by every branch — one plan for all futures. Setting +By default (`solve.stochastic_invest_method = none`) a stochastic model makes a **single** investment plan shared by every branch — one plan for all futures. The stochastic-investment modes below differ in *what is decided before the uncertainty is revealed*. -``` -solve.stochastic_invest_method = recourse -``` +#### Standard two-stage stochastic investment (the main mode) + +The usual goal is a genuine two-stage plan: **one shared here-and-now investment decision, made before the uncertainty resolves, with every scenario then operating that fleet freely from the first timestep** according to its own realization. The investment is the first-stage (non-anticipative) decision; *all operations are second-stage recourse* — nothing operational is tied across the branches. + +For a model that invests in a single period this is available today: keep `stochastic_invest_method = none` (one shared investment) and set `non_anticipativity_periods = []` (operations branch freely from t0 — see *What the model ties across branches* above). The result is the classic stochastic capacity-expansion model — commit one fleet, minimise the probability-weighted cost of operating it across the scenarios. -instead lets each branch (the realized one included) invest on its own data at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are output only under `model.output_horizon` (a debug flag); committed results always report the realized scenario. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). +For a **multi-period** investment horizon the ideal is that only the *first* period's investment is shared (the genuine here-and-now decision) while later-period investment adapts per scenario (recourse), since by then some uncertainty has resolved. That refinement — an investment non-anticipativity *window* (investment shared over the first period(s), per-branch after, operations free throughout) — is a planned addition. Until it lands, `none` shares investment across **all** periods (so use it when investment is effectively single-period) and `recourse` (below) shares **none**; neither gives the shared-first-period / recourse-later split on its own. -When every branch starts at the solve's **first** step, `recourse` is a *wait-and-see* analysis: the objective is the expected value of per-scenario optimal plans weighted by their probabilities. It is **not** a single hedged plan — so it typically costs less than `none` (each branch gets tailor-made capacity) and is not directly comparable across the flag (turning on `recourse` also corrects the realized branch's own investment-annuity windows). This approach might have some very specific use cases, but the main stochastic investment mode is the hedged mid-horizon form below. +#### Per-scenario (wait-and-see) investment -#### Hedged two-stage investment (mid-horizon reveal) +`stochastic_invest_method = recourse` lets each branch (the realized one included) invest on its own data at and after the branching period, with probability-weighted investment costs and per-scenario limits. When branches start at the solve's **first** step this is a *wait-and-see* analysis — each scenario has perfect foresight, so the objective is an EVPI lower bound, **not** a committed plan (it typically costs less than any single plan can). Useful for scenario screening and per-scenario capacity ranges; it is not a plan you can build. Non-realized branch investments are output only under `model.output_horizon` (a debug flag); committed results always report the realized scenario. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). -In hedged two-stage investment the uncertainty is revealed **mid-horizon**: keep the first periods deterministic (a single, shared *here-and-now* investment) and let the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, and put the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). +#### Hedged mid-horizon reveal (first periods deterministic for investment *and* operations) -With this shape the pre-reveal periods stay a single real-named trunk with **one shared `v_invest`** — the genuine first-stage (here-and-now) decision. Its non-anticipativity holds by construction (there is only one variable, so nothing to tie), and its cost is counted once at full weight. The post-reveal periods fan per branch exactly as the wait-and-see case does, giving the second-stage recourse investments. The result is a real **two-stage hedge**: the shared first stage is chosen knowing only the distribution of futures, and its objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS). Only the realized trunk is committed to the output. +A third structure keeps the first periods **fully deterministic — a single shared trunk for both investment and operations** — and lets the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, with the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). -The multi-period restriction of earlier versions is lifted: a branch may now start at any period boundary, and the pre-reveal horizon can be any number of periods. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. +The pre-reveal periods stay a single real-named trunk with one shared `v_invest` *and* one shared operational schedule — non-anticipativity holds by construction (there is only one variable per pre-reveal period, so nothing to tie). The post-reveal periods fan per branch (second-stage recourse). Note how this differs from the standard mode above: here the **near-term operations are shared (committed) too**, not just the investment — appropriate when the first periods genuinely are deterministic (a committed near-term operating schedule) rather than a first period that should already be operated per scenario. The objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS); only the realized trunk is output. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. ## How to use CPLEX as the solver From db97614d5b77831f6a4dccde8136fc6568a3a098 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 21:26:46 +0300 Subject: [PATCH 43/87] feat(stochastic): invest-NA window schema (v72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add solve.non_anticipativity_invest_periods — the standard two-stage stochastic-investment mode knob (Array of periods over which v_invest / v_divest are tied across stochastic branches, a single shared here-and-now decision). Default unset == [] == NO tie, byte-identical to today's recourse/none. - db_migration: _migrate_v72_non_anticipativity_invest_periods mirrors the v71 non_anticipativity_periods migration (Array, null/null default, no value list, solve_advanced group); FLEXTOOL_DB_VERSION 71 -> 72 + dispatch arm. - PARAMETER_TYPES row (DIMENSIONLESS) so autoscale does not silently degrade to an un-scaled LP. - export_settings.yaml: split_params.solve_period.params AND data_type_overrides.nested_periodic (both locations, F6). - _solve_config: read via plain periods_to_tuples (unset == [] == no tie, so no tri-state needed) + dup_map_list propagation for rolling/laddered sub-solves. - Regenerated all four CI artifacts (spinedb_schema.json master template, canonical databases, test fixtures, generate_canonical); the only diff is the mechanical v72 version bump + the new parameter-definition row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_solve_config.py | 24 +++++- .../autoscale/_quantity_types.py | 2 + .../export_to_tabular/export_settings.yaml | 3 +- .../howto_aggregate_output.json | 10 ++- .../howto_connections.json | 10 ++- .../canonical_databases/howto_demand.json | 10 ++- .../howto_hydro_reservoir.json | 10 ++- .../howto_hydro_reservoir_with_pump.json | 10 ++- .../howto_non_sync_and_curtailment.json | 10 ++- .../howto_ramp_and_start_up.json | 10 ++- .../howto_stochastics.json | 10 ++- .../templates_examples.json | 10 ++- .../templates_time_settings_only.json | 10 ++- flextool/schemas/spinedb_schema.json | 10 ++- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 76 +++++++++++++++++++ tests/fixtures/branch2_parent_period.json | 13 +++- tests/fixtures/case14_dc_power_flow.json | 13 +++- tests/fixtures/h2_trade_parity.json | 13 +++- tests/fixtures/lh2_three_region.json | 13 +++- tests/fixtures/stochastics.json | 13 +++- tests/fixtures/stochastics_pbt_inflow.json | 13 +++- tests/fixtures/tests.json | 13 +++- 23 files changed, 284 insertions(+), 24 deletions(-) diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 7bb40f453..be4422b04 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -13,7 +13,7 @@ Loading order matters (``make_roll_counter`` → ``get_period_timesets`` → 4× ``periods_to_tuples`` → ``non_anticipativity_periods_to_tristate``) because each may call - :meth:`duplicate_solve`, which mutates 20 sibling dicts in lockstep. + :meth:`duplicate_solve`, which mutates 21 sibling dicts in lockstep. * The DB schema is assumed to be v50+. v50 moved ``new_stepduration`` from ``timeset`` to ``solve``; ``update_flextool/db_migration.py`` handles upgrades for older DBs @@ -334,6 +334,13 @@ def __init__( # A plain dict (not a defaultdict) so a missing key reads back as # None (unset) rather than an empty list ([] = the free-ops mode). self.non_anticipativity_periods: dict[str, list | None] = {} + # Invest-NA window — the standard two-stage stochastic-investment + # mode. Periods over which v_invest / v_divest are TIED across + # stochastic branches (a single shared here-and-now decision). + # Unlike the operational window above, unset == [] == NO tie, so a + # plain defaultdict(list) (from ``periods_to_tuples``) is + # byte-parity-safe (both empty-ish states resolve to no ties). + self.non_anticipativity_invest_periods: defaultdict = defaultdict(list) # Mutable tracking — populated during the recursive solve loop. self.real_solves: list[str] = [] @@ -659,7 +666,7 @@ def _opt_int(raw: dict, key: str) -> int | None: ) # Computed fields — loading order MUST be preserved exactly. - # ``duplicate_solve`` mutates 20 sibling dicts in lockstep, so + # ``duplicate_solve`` mutates 21 sibling dicts in lockstep, so # any reordering desyncs them and downstream reads silently # produce empty/zero results. obj.roll_counter = obj.make_roll_counter() @@ -686,6 +693,14 @@ def _opt_int(raw: dict, key: str) -> int | None: db=db, cl="solve", par="non_anticipativity_periods" ) ) + # Invest-NA window (v72) — plain periods_to_tuples: unset == [] == + # no tie, so a defaultdict(list) is byte-parity-safe (no tri-state + # needed). Read AFTER the period dicts so duplicate_solve has + # registered any duplicated solve names; the value for those is + # carried by duplicate_solve's dup_map_list. + obj.non_anticipativity_invest_periods = obj.periods_to_tuples( + db=db, cl="solve", par="non_anticipativity_invest_periods" + ) return obj @@ -830,9 +845,9 @@ def duplicate_solve( """Duplicate every solve-level dict entry from *old_solve* under *new_name*. - Mutates 20 sibling dicts (and ``model_solve`` when + Mutates 21 sibling dicts (and ``model_solve`` when *update_model_solves* is set) so downstream readers can address - the duplicated solve transparently. One of the 20 — + the duplicated solve transparently. One of the 21 — ``non_anticipativity_periods`` — is a plain tri-state dict, not a defaultdict; the ``if old_solve in dup_map.keys()`` guard copies it the same way (an unset parent solve simply has no key to @@ -863,6 +878,7 @@ def duplicate_solve( self.invest_periods, self.fix_storage_periods, self.non_anticipativity_periods, + self.non_anticipativity_invest_periods, self.decomposition, self.benders_max_iter, self.benders_tolerance, diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 43765e09f..184dd0ccb 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -343,6 +343,8 @@ class QuantityType(Enum): # Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other ti... ('node_type', 'node'): QuantityType.DIMENSIONLESS, # Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' ... + ('non_anticipativity_invest_periods', 'solve'): QuantityType.DIMENSIONLESS, + # Array of periods over which investment (v_invest/v_divest) is tied across stochastic branches (the standard two-stage mode). Unset/empty = no tie. ('non_anticipativity_periods', 'solve'): QuantityType.DIMENSIONLESS, # Array of periods over which operational non-anticipativity is enforced (Slice G). Unset = legacy realized_dispatch u fix_storage window; empty = operations free from t0. ('non_synchronous_limit', 'group'): QuantityType.FRACTION, diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 222a6088c..733752f12 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -89,7 +89,7 @@ split_params: solve_period: params: ["period_timeset", "years_represented", "realized_periods", "invest_periods", "realized_invest_periods", "fix_storage_periods", - "non_anticipativity_periods"] + "non_anticipativity_periods", "non_anticipativity_invest_periods"] rolling_parameters: params: ["rolling_solve_jump", "rolling_solve_horizon", "rolling_duration"] solve_s_stochastic_branches: @@ -297,6 +297,7 @@ data_type_overrides: realized_periods: "string (2d-map)" fix_storage_periods: "string (2d-map)" non_anticipativity_periods: "string (2d-map)" + non_anticipativity_invest_periods: "string (2d-map)" # Parameters that are ONLY period-indexed (never time-indexed). # Used to filter them out of _t sheets. Derived from parameter descriptions. diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index ebdc40f59..326a94f3e 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -1578,7 +1578,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -2186,6 +2186,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index 2383cb648..1f8cd67b2 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -1213,7 +1213,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1832,6 +1832,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index 1f1b1956c..778bc42f5 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -1129,7 +1129,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1748,6 +1748,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index bb17a0ff6..e44f5cdb3 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -1382,7 +1382,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -2001,6 +2001,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index 33d8b43c2..7b828c950 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -1231,7 +1231,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1850,6 +1850,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index 63d886b10..9bdabd5a4 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -1260,7 +1260,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1868,6 +1868,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index 4900aa62a..db5cbdbef 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -1200,7 +1200,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1819,6 +1819,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index 99fbece25..55b187f35 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -1321,7 +1321,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1940,6 +1940,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index 9be3c8814..38a7ca41c 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -2503,7 +2503,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -3111,6 +3111,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index fb9cda831..7dd858b4b 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -1079,7 +1079,7 @@ "model", "version", [ - "71", + "72", "float" ], null, @@ -1695,6 +1695,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index b2a459d6a..6ec5bbcf3 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -1611,7 +1611,7 @@ [ "model", "version", - 71.0, + 72.0, null, "Contains database version information.", "model" @@ -2160,6 +2160,14 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + null, + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index d945171ac..2e2154153 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 71 +FLEXTOOL_DB_VERSION: int = 72 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 23412e1e6..82e336605 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1765,6 +1765,8 @@ def _is_yes(pv): _migrate_v70_stochastic_invest_method(db) elif next_version == 71: _migrate_v71_non_anticipativity_periods(db) + elif next_version == 72: + _migrate_v72_non_anticipativity_invest_periods(db) else: print("Version invalid") last_completed_version = next_version @@ -3916,6 +3918,80 @@ def _migrate_v71_non_anticipativity_periods(db) -> None: ) +def _migrate_v72_non_anticipativity_invest_periods(db) -> None: + """Add the solve.non_anticipativity_invest_periods knob (v71 -> v72). + + The invest-NA window is the standard two-stage stochastic-investment + mode: an Array-of-periods parameter selecting the periods over which + ``v_invest`` / ``v_divest`` are TIED across stochastic branches (a + single shared here-and-now decision). It pins the four + ``non_anticipativity_invest_p/n`` and ``non_anticipativity_divest_p/n`` + constraint families across the branch fan of those periods: + + * unset (default) OR empty Array ``[]`` -> NO tie, byte-identical to + prior behaviour (``recourse`` stays per-branch everywhere; ``none`` + stays shared); + * ``[p, ...]`` -> tie invest/divest at each branch's copy of those + periods to the realized anchor -> shared first-stage investment with + per-branch (recourse) investment in later periods. + + Unlike the operational ``non_anticipativity_periods`` (v71), the + unset/``[]`` distinction carries no behaviour here (both = no tie), + so it is read by the plain ``periods_to_tuples`` (not a tri-state). + + Array-shaped (read by ``periods_to_tuples``, same as + ``realized_periods`` / ``non_anticipativity_periods``): + ``default_value`` and ``default_type`` are ``None`` and it has NO + parameter_value_list. Migrating a DB adds only the definition (no + per-solve value), so every migrated solve resolves to unset -> no + tie -> today's behaviour. Grouped under 'solve_advanced' (created if + the DB lacks it). + """ + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_invest_periods", + default_value=None, + default_type=None, + description=( + "Array of periods over which investment is non-anticipative " + "(v_invest and v_divest tied across stochastic branches — a " + "single shared here-and-now decision). Unset (default) or an " + "empty Array means NO tie (byte-identical to prior behaviour: " + "recourse stays per-branch, none stays shared). A period list " + "-- e.g. the first period -- ties investment over those " + "periods while later periods invest per-branch (recourse), " + "the standard two-stage stochastic capacity-expansion mode. " + "Requires stochastic_invest_method=recourse; a no-op under " + "none (investment is already shared across all periods)." + ), + ) + # Attach to the 'solve_advanced' parameter group, creating it if the + # DB lacks it (mirrors v70/v71; see those migrations for the + # get-or-create rationale — minimal fixtures seeded above v44 carry + # no groups). + if not db.get_item("parameter_group", name="solve_advanced"): + db.add_update_item( + "parameter_group", + name="solve_advanced", + color="b56f6f", + priority=87, + ) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_invest_periods", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v72: added solve.non_anticipativity_invest_periods (Array of " + "periods, default unset); the standard two-stage stochastic " + "investment mode — ties v_invest/v_divest across branches over " + "the window (shared here-and-now decision), recourse later." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 2383a724f..615510019 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index fb41c2684..8b8430a13 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -3579,7 +3579,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -4334,6 +4334,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index 660af69e9..99145a099 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -8673,7 +8673,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -9428,6 +9428,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index 393515bc3..6ae657595 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -4521,7 +4521,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -5276,6 +5276,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index a29f3db3f..05cadb3cb 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index afb269b2b..d606bb00f 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -2527,7 +2527,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -3293,6 +3293,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index c0eaf47b2..e6c550e57 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -3467,7 +3467,7 @@ "model", "version", [ - "NzE=", + "NzI=", "float" ], null, @@ -4222,6 +4222,17 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], [ "solve", "non_anticipativity_periods", From 8a098834dc8568df5e1d9892508a80494c1901ff Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 21:26:59 +0300 Subject: [PATCH 44/87] feat(stochastic): non_anticipativity_invest constraint families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populate pd_non_anticipativity by window membership and tie invest AND divest across branches over that window (design §3-§5). - _emit_per_solve: emit solve_data/pd_non_anticipativity_invest_periods.csv ONLY when the window is non-empty (absent file -> legacy empty frame -> byte-parity, no new CSV for unset fixtures); threaded from _emit_solve_time.run. - _derived_branch.pd_non_anticipativity_lf: new tie_periods arg + ctx/CSV reader (_read_invest_tie_periods). When resolved, emit (anchor_of[m], m) for every synthetic member whose anchor is in the window (supersedes the never-supplied branch_start reveal-boundary gate, which stays as the byte-parity legacy path -> provably empty today). - model.py: _add_non_anticipativity_invest_constraints emits all four families non_anticipativity_invest_p/n and _divest_p/n (divest is mandatory, F2/§4.4 — tying invest alone leaks net first-stage capacity). Gated on recourse_invest + non-empty frame. The cross-Enum domain guard carries the db_pairs Utf8 dance (F3): pd_non_anticipativity.b is branch-Enum while pd_invest_set.d renamed d->b is period-Enum, so both b keys are cast to Utf8 for the guard join and restored via cast_dim. - autoscale: register all four family names (CstrFamily(None), RHS=0) + pin them in test_registry_coverage dynamic samples (passed as a variable, invisible to the literal grep). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 63 +++++++++++- flextool/engine_polars/_emit_per_solve.py | 23 +++++ flextool/engine_polars/_emit_solve_time.py | 7 +- .../engine_polars/autoscale/_layer2_types.py | 8 ++ flextool/engine_polars/model.py | 99 +++++++++++++++++++ .../autoscale/test_registry_coverage.py | 7 ++ 6 files changed, 205 insertions(+), 2 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 080a4b39f..7b32152a5 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1162,6 +1162,41 @@ def d_leaf_df( ctx=ctx, provider=provider).collect() +def _read_invest_tie_periods( + workdir: Path | None, + *, + ctx: "object | None" = None, + provider: "object | None" = None, +) -> "frozenset[str] | None": + """Resolve the v72 invest-NA window (the periods to tie) for this solve. + + Returns ``None`` when the window is unset (no + ``pd_non_anticipativity_invest_periods.csv`` was emitted and ctx + carries nothing) — the byte-parity legacy path. Returns a + ``frozenset`` of period tokens otherwise (possibly empty, though the + emitter only writes the CSV for a non-empty window). + + ctx-first (``SolveContext.non_anticipativity_invest_periods`` — a + list of period tokens or ``(p, p)`` tuples), then the per-solve CSV + materialised by ``_emit_per_solve.emit_per_solve_sets``. + """ + if ctx is not None: + w = getattr(ctx, "non_anticipativity_invest_periods", None) + if w is not None: + return frozenset( + str(p[0] if isinstance(p, (tuple, list)) else p) for p in w) + if workdir is None: + return None + p = Path(workdir) / "solve_data" / "pd_non_anticipativity_invest_periods.csv" + df = _provider_get(provider, p) + if df is None: + return None + if df.height == 0: + return frozenset() + col = "period" if "period" in df.columns else df.columns[0] + return frozenset(str(v) for v in df[col].to_list() if v is not None) + + def pd_non_anticipativity_lf( workdir: Path | None, source: "InputSource | None" = None, @@ -1170,6 +1205,7 @@ def pd_non_anticipativity_lf( ctx: "object | None" = None, provider: "object | None" = None, branch_start: pl.DataFrame | None = None, + tie_periods: "frozenset[str] | None" = None, ) -> pl.LazyFrame: """``pd_non_anticipativity`` — (d, b) invest-NA period pairs. @@ -1182,6 +1218,16 @@ def pd_non_anticipativity_lf( invest at the branching period is per-scenario by owner decision, plan §7). + The v72 invest-NA window drives the emit by MEMBERSHIP: ``tie_periods`` + is the resolved window (a ``frozenset`` of anchor periods). When + supplied, ``(anchor_of[m], m)`` is emitted for every synthetic member + ``m`` whose anchor lies in the window — the standard two-stage mode + (design §3.2). When ``tie_periods`` is ``None`` it is auto-resolved + from ctx / the per-solve CSV via :func:`_read_invest_tie_periods`; + still ``None`` (unset) falls back to the legacy ``branch_start`` + reveal-boundary path below, which is provably EMPTY for every + currently-constructible solve (byte-parity, design §2.3). + ``branch_start`` (optional, ``[time_branch, d_start]``) supplies the authoritative reveal period per time-branch — the Slice E hook. When ``None`` (the current-universe normal; the runner-side @@ -1199,6 +1245,9 @@ def pd_non_anticipativity_lf( """ schema = {"d": schema_dtype(_enums, "d"), "b": schema_dtype(_enums, "b")} + if tie_periods is None: + tie_periods = _read_invest_tie_periods( + workdir, ctx=ctx, provider=provider) pb_rows, piu, tb_of = _lineage_inputs( workdir, source, active_solve, ctx=ctx, provider=provider) if not piu: @@ -1207,6 +1256,17 @@ def pd_non_anticipativity_lf( pb_rows, piu, tb_of) if not synthetic: return _empty_lf(schema) + if tie_periods is not None: + # v72 window-membership emit (design §3.2): tie every synthetic + # member whose real-named anchor is in the window. Supersedes the + # reveal-boundary gate below (which cannot express an at-branching- + # period tie). An empty window -> no rows -> empty frame. + rows = [(anchor_of[m], m) for m in synthetic + if anchor_of[m] in tie_periods] + if not rows: + return _empty_lf(schema) + return _finalize_lineage_frame(rows, "d", "d", "b", "b", + "pd_non_anticipativity") bs_of: dict[str, str] = {} if branch_start is not None and branch_start.height > 0: bs_of = {str(tb): str(d) for tb, d in @@ -1248,12 +1308,13 @@ def pd_non_anticipativity_df( ctx: "object | None" = None, provider: "object | None" = None, branch_start: pl.DataFrame | None = None, + tie_periods: "frozenset[str] | None" = None, ) -> pl.DataFrame: """Collect wrapper for :func:`pd_non_anticipativity_lf` — always a typed frame, never ``None`` (design §2.5).""" return pd_non_anticipativity_lf( workdir, source, active_solve, ctx=ctx, provider=provider, - branch_start=branch_start).collect() + branch_start=branch_start, tie_periods=tie_periods).collect() # --------------------------------------------------------------------------- diff --git a/flextool/engine_polars/_emit_per_solve.py b/flextool/engine_polars/_emit_per_solve.py index f37944f12..d3e44b240 100644 --- a/flextool/engine_polars/_emit_per_solve.py +++ b/flextool/engine_polars/_emit_per_solve.py @@ -132,6 +132,7 @@ def emit_per_solve_sets( *, provider, na_window: "list | None" = None, + na_invest_window: "list | None" = None, ) -> None: """Emit ``per_solve_sets`` to the Provider. Emits the same ~24 frames under ``solve_data/`` keys via @@ -152,6 +153,17 @@ def emit_per_solve_sets( * ``[(p, p), ...]`` -> those periods' timesteps, intersected with ``steps_in_use`` (the ONLY domain guard for the storage family, which joins the (d,t) set on ``d`` alone — F7). + + *na_invest_window* is the resolved v72 invest-NA window + (``SolveConfig.non_anticipativity_invest_periods``, plain + ``periods_to_tuples`` — unset == [] == no tie). When non-empty it + materialises ``pd_non_anticipativity_invest_periods.csv`` (a + one-column period list), read back by + ``_derived_branch.pd_non_anticipativity_lf`` to populate the invest + tie-set. When unset/empty NO file is emitted, so the frame builder's + reader returns ``None`` -> the legacy branch_start path -> a provably + empty frame -> zero constraints -> byte-identical to prior behaviour + (and no new CSV appears in any existing fixture's solve_data). """ input_dir = solve_data_dir.parent / "input" @@ -386,6 +398,17 @@ def emit_per_solve_sets( _emit_tuples(provider, "solve_data/dt_non_anticipativity_set.csv", ("period", "time"), list(dtna_seen.keys())) + # v72 — invest-NA window. Materialise the tie period list ONLY when + # the window is non-empty; absent file -> reader returns None -> + # legacy empty frame -> byte-parity (no new CSV for unset fixtures). + if na_invest_window: + na_invest_periods = list(dict.fromkeys( + p for (p, _p_in) in na_invest_window)) + _emit_singles( + provider, + "solve_data/pd_non_anticipativity_invest_periods.csv", + "period", na_invest_periods) + cn_df = _read_csv(input_dir / "commodity__node.csv", ["commodity", "node"], provider=provider) cn_pairs = [ diff --git a/flextool/engine_polars/_emit_solve_time.py b/flextool/engine_polars/_emit_solve_time.py index 6469e28f6..a114af47d 100644 --- a/flextool/engine_polars/_emit_solve_time.py +++ b/flextool/engine_polars/_emit_solve_time.py @@ -156,8 +156,13 @@ def _ck(label: str) -> None: # ``solve_name`` here is ``complete_solve[solve]`` (the caller passes # it), the same key the window dict is populated under. na_window = state.solve.non_anticipativity_periods.get(solve_name) + # v72 — invest-NA window (unset == [] == no tie). .get avoids the + # defaultdict's default-factory insertion; None/[] emit no CSV. + na_invest_window = state.solve.non_anticipativity_invest_periods.get( + solve_name) _per_solve.emit_per_solve_sets( - solve_data_dir, provider=provider, na_window=na_window + solve_data_dir, provider=provider, na_window=na_window, + na_invest_window=na_invest_window, ) _ck("per_solve_sets") diff --git a/flextool/engine_polars/autoscale/_layer2_types.py b/flextool/engine_polars/autoscale/_layer2_types.py index 8a27a028d..c96c3da19 100644 --- a/flextool/engine_polars/autoscale/_layer2_types.py +++ b/flextool/engine_polars/autoscale/_layer2_types.py @@ -476,6 +476,14 @@ class CstrFamily: "non_anticipativity_online_linear": CstrFamily(None), "non_anticipativity_reserve": CstrFamily(None), + # ── Investment non-anticipativity (cross-branch invest/divest + # equality; v72). RHS = 0; skip per-row. Divest is mandatory (§4.4). + # flextool/engine_polars/model.py:_add_non_anticipativity_invest_constraints + "non_anticipativity_invest_p": CstrFamily(None), + "non_anticipativity_invest_n": CstrFamily(None), + "non_anticipativity_divest_p": CstrFamily(None), + "non_anticipativity_divest_n": CstrFamily(None), + # ── Profile constraints (per timestep). # Node profiles emit at model.py:2879 with names # ``profile_state_upper_limit`` / ``_lower_limit`` / ``_fixed``; diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 29de723e4..d6f669fe4 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -446,6 +446,85 @@ def _add_non_anticipativity_constraints( ) +def _add_non_anticipativity_invest_constraints( + m, d, *, + v_invest_p, v_invest_n, v_divest_p, v_divest_n, +) -> None: + """Emit the four invest non-anticipativity families (v72, design §4). + + Ties ``v_invest`` / ``v_divest`` across stochastic branches over the + resolved window ``pd_non_anticipativity`` (d, b): for every window + pair the branch copy's invest/divest column ``[·, b]`` is pinned equal + to the realized anchor's column ``[·, d]`` (RHS 0). Four families: + + * ``non_anticipativity_invest_p`` — process invest (p, d) vs (p, b) + * ``non_anticipativity_invest_n`` — node/storage invest (n, d)/(n, b) + * ``non_anticipativity_divest_p`` — process divest (mandatory, F2) + * ``non_anticipativity_divest_n`` — node/storage divest + + Tying invest AND divest is mandatory (design §4.4): divest fans under + recourse exactly as invest does, so a tied invest with a per-branch + divest would leak the NET first-stage capacity + ``(existing + invest − divest)`` across branches on every + invest+divest-eligible (reinvest / lifetime) entity. + + The domain guard (design §4.2-4.3, F3) is load-bearing AND cross-Enum: + ``pd_non_anticipativity.b`` is on the BRANCH axis (its values are + period-copy tokens like ``p2035_low``) while ``pd_invest_set.d`` + renamed ``d→b`` is on the PERIOD axis — polars refuses a join between + the two Enum vocabularies. So the guard join casts BOTH ``b`` keys to + Utf8 (the ``db_pairs`` dance, :func:`build_flextool` ~line 4440) and + restores the axis types via ``cast_dim`` afterwards. This is the + FIRST test-covered use of this cross-Enum guard join (the operational + online guard is dormant), so the solver-level invest-tie test is its + mandatory coverage. + """ + pdb = d.pd_non_anticipativity # (d, b) + if pdb is None or pdb.height == 0: + return + + def _tie(name: str, v, inv_set, ent: str) -> None: + if v is None or inv_set is None or inv_set.height == 0: + return + pd_inv = inv_set.select(ent, "d").unique() # (ent, d) + # d-side join is same-axis (both period-Enum) → native. + cstr = pd_inv.join(pdb, on="d", how="inner") # (ent, d, b) + if cstr.height == 0: + return + # ── F3 guard: (ent, b) must be a declared invest cell. b is + # branch-Enum on cstr, period-Enum on pd_inv.d → cast both b + # keys to Utf8 for the join, restore axis types afterwards. + guard = (pd_inv.rename({"d": "b"}) + .with_columns(pl.col("b").cast(pl.Utf8))) + cstr_over = (cstr + .with_columns(pl.col("b").cast(pl.Utf8)) + .join(guard, on=[ent, "b"], how="inner") + .with_columns(cast_dim(pl.col("d"), None, "d"), + cast_dim(pl.col("b"), None, "b")) + .select(ent, "d", "b").unique()) + if cstr_over.height == 0: + return + v_at_b = Var( + name=v.name + "__nab", + dims=(ent, "b"), + frame=v.frame.rename({"d": "b"}), + lower=v.lower, upper=v.upper, + ) + m.add_cstr( + name, + over = cstr_over, + sense = "==", + lhs_terms = {"v_d": v, + "v_b": -v_at_b}, + rhs_terms = {}, + ) + + _tie("non_anticipativity_invest_p", v_invest_p, d.pd_invest_set, "p") + _tie("non_anticipativity_invest_n", v_invest_n, d.nd_invest_set, "n") + _tie("non_anticipativity_divest_p", v_divest_p, d.pd_divest_set, "p") + _tie("non_anticipativity_divest_n", v_divest_n, d.nd_divest_set, "n") + + def build_flextool(m, d, *, include_existing_fixed_cost: bool = False, scale_the_objective: float = 1.0) -> None: """Build the flextool LP into ``m`` from data ``d``. @@ -4467,6 +4546,26 @@ def _wt(param): has_minload_eff = has_minload_eff, ) + # ─── Investment non-anticipativity (v72 — the standard two-stage + # stochastic-investment mode) ──────────────────────────────────────── + # Ties v_invest / v_divest across stochastic branches over the resolved + # window pd_non_anticipativity (design §4). Gated on recourse_invest + # (per-branch invest columns must exist to tie) AND a non-empty frame; + # under the unset window the frame is provably empty (byte-parity) and + # this block never fires. Under stochastic_invest_method=none the frame + # may build but the domain guard drops every pair (no fanned invest + # column) — a graceful no-op. + if (getattr(d, "recourse_invest", False) + and d.pd_non_anticipativity is not None + and d.pd_non_anticipativity.height > 0): + _add_non_anticipativity_invest_constraints( + m, d, + v_invest_p = locals().get("v_invest_p") if has_invest_p else None, + v_invest_n = locals().get("v_invest_n") if has_invest_n else None, + v_divest_p = locals().get("v_divest_p") if has_divest_p else None, + v_divest_n = locals().get("v_divest_n") if has_divest_n else None, + ) + # Apply objective scaling if provided (default 1.0 = no scaling). if scale_the_objective != 1.0: obj = obj * scale_the_objective diff --git a/tests/engine_polars/autoscale/test_registry_coverage.py b/tests/engine_polars/autoscale/test_registry_coverage.py index 9bd6cdde5..c113ba69f 100644 --- a/tests/engine_polars/autoscale/test_registry_coverage.py +++ b/tests/engine_polars/autoscale/test_registry_coverage.py @@ -228,6 +228,13 @@ def test_constraint_registry_covers_engine_sources() -> None: "maxDivest_entity_total_path", "maxDivest_entity_total_path_n", "maxDivestGroup_entity_total_path_p", "maxDivestGroup_entity_total_path_n", + # Invest non-anticipativity (v72) — model.py + # ``_add_non_anticipativity_invest_constraints`` passes the family + # name as a variable (the ``_tie`` helper), so the literal grep above + # cannot see them. All four resolve via an exact CONSTRAINT_FAMILIES + # entry (RHS = 0, CstrFamily(None)); divest is mandatory (§4.4). + "non_anticipativity_invest_p", "non_anticipativity_invest_n", + "non_anticipativity_divest_p", "non_anticipativity_divest_n", ] From 81df8dca5d74a17d3b10c4a9c26cc7c792c1b904 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 21:36:24 +0300 Subject: [PATCH 45/87] test(stochastic): invest-NA window hand-calc + families + v72 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the hedge builder with fan-at-t0 invest-NA scenarios and add the solver/structural gate (design §9). - build_stoch_two_period_hedge: new scenarios on the fan-at-t0 (ws) topology — hedge_na_window (window=[p2035] + ops free), hedge_na_divest (base invest_retire_total + existing 50 + salvage_value so v_divest exists), hedge_na_none (stochastic_invest_method=none). - test_invest_na_window: T-Main obj=173,250 with p2035 v_invest EQUAL across branches (60) and p2040 per-branch (recourse 60/0); T-WS control 157,500 with RP−WS=15,750; T-Frame {(p2035,p2035_low)} (empty when unset); T-Weight branch-weight partition 0.25/0.75; T-C invest rows; T-Divest divest tied + net capacity equal across branches; T-None graceful no-op; T-Reg four families registered. All under FLEXTOOL_AUTOSCALE_STRICT=1 (T-Main is the first coverage of the F3 cross-Enum guard join). - model.py: T-None advisory log — window set on a non-recourse solve is a graceful no-op (never mutates the problem, §4.5). - test_v72 migration: idempotency + definition shape (Array, null/null default, solve_advanced group), mirroring the v71 test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/model.py | 32 +- tests/engine_polars/test_invest_na_window.py | 276 ++++++++++++++++++ .../fixtures/build_stoch_two_period_hedge.py | 60 ++++ tests/fixtures/stoch_two_period_hedge.json | 246 ++++++++++++++-- ...t_v72_non_anticipativity_invest_periods.py | 175 +++++++++++ 5 files changed, 753 insertions(+), 36 deletions(-) create mode 100644 tests/engine_polars/test_invest_na_window.py create mode 100644 tests/update_flextool/test_v72_non_anticipativity_invest_periods.py diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index d6f669fe4..3c915bb53 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -4555,16 +4555,28 @@ def _wt(param): # this block never fires. Under stochastic_invest_method=none the frame # may build but the domain guard drops every pair (no fanned invest # column) — a graceful no-op. - if (getattr(d, "recourse_invest", False) - and d.pd_non_anticipativity is not None - and d.pd_non_anticipativity.height > 0): - _add_non_anticipativity_invest_constraints( - m, d, - v_invest_p = locals().get("v_invest_p") if has_invest_p else None, - v_invest_n = locals().get("v_invest_n") if has_invest_n else None, - v_divest_p = locals().get("v_divest_p") if has_divest_p else None, - v_divest_n = locals().get("v_divest_n") if has_divest_n else None, - ) + _pdna = d.pd_non_anticipativity + if _pdna is not None and _pdna.height > 0: + if getattr(d, "recourse_invest", False): + _add_non_anticipativity_invest_constraints( + m, d, + v_invest_p = locals().get("v_invest_p") if has_invest_p else None, + v_invest_n = locals().get("v_invest_n") if has_invest_n else None, + v_divest_p = locals().get("v_divest_p") if has_divest_p else None, + v_divest_n = locals().get("v_divest_n") if has_divest_n else None, + ) + else: + # Window set on a non-recourse (none) solve: investment is + # already shared across all periods, so there is nothing to + # tie. Graceful no-op + advisory (never mutates the problem, + # design §4.5 / OQ-3). + _LOG.info( + "non_anticipativity_invest_periods is set but " + "stochastic_invest_method is not 'recourse'; investment is " + "already shared across all periods, so the invest-NA window " + "has no effect (use 'recourse' for shared-first-period / " + "recourse-later)." + ) # Apply objective scaling if provided (default 1.0 = no scaling). if scale_the_objective != 1.0: diff --git a/tests/engine_polars/test_invest_na_window.py b/tests/engine_polars/test_invest_na_window.py new file mode 100644 index 000000000..9c39293f4 --- /dev/null +++ b/tests/engine_polars/test_invest_na_window.py @@ -0,0 +1,276 @@ +"""Invest-NA window (v72) — the standard two-stage stochastic-investment +mode: investment non-anticipative (a single shared here-and-now decision) +over the first period(s), per-branch (recourse) investment later, +operations free per scenario from t0. + +Design: ``specs/invest_na_window_design.md`` (Revision 2). + +The new solve param ``non_anticipativity_invest_periods`` ties +``v_invest`` / ``v_divest`` across stochastic branches over the resolved +window. Composed with ``stochastic_invest_method=recourse`` (per-branch +invest columns to tie) and ``non_anticipativity_periods=[]`` (ops free) it +delivers the main mode. + +Fixture ``stoch_two_period_hedge.json`` (built via +``build_stoch_two_period_hedge.py``) re-uses the Slice E hedge topology +with the branches FANNING AT t0 (the ``ws`` alternative), plus: + + * ``hedge_na_window`` — fan-at-t0 + ``non_anticipativity_invest_periods + =[p2035]`` + ``non_anticipativity_periods=[]``. The tie reproduces + the Option-B hedge **RP = 173 250** BY CONSTRAINT (§9.3): p2035 invest + shared across branches, p2040 invest per-branch (recourse). + * ``hedge_ws`` — the same fan-at-t0 topology with the window UNSET — + today's wait-and-see control, **WS = 157 500**. ``RP − WS = 15 750``. + * ``hedge_na_divest`` — ``base`` made ``invest_retire_total`` + existing + 50 so ``v_divest`` exists; the divest ties fire too (F2). + * ``hedge_na_none`` — the window set on a ``stochastic_invest_method + =none`` solve → graceful no-op (no invest-NA rows). +""" +from __future__ import annotations + +import os + +import polars as pl +import pytest + +SOLVE = "stoch_2p_hedge" + +RP = 173_250.0 # main mode (window = [p2035]) — shared first stage +WS = 157_500.0 # wait-and-see control (window unset) +EVPI = RP - WS # = 15 750 + + +# --------------------------------------------------------------------------- +# Workdir fixtures (full cascade) — mirror test_recourse_invest_hedge.py +# --------------------------------------------------------------------------- + + +def _strict(scenario_workdir, name): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir(name, db_fixture="stoch_two_period_hedge") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def na_window_wf(scenario_workdir): + return _strict(scenario_workdir, "hedge_na_window") + + +@pytest.fixture(scope="module") +def na_ws_wf(scenario_workdir): + return scenario_workdir("hedge_ws", db_fixture="stoch_two_period_hedge") + + +@pytest.fixture(scope="module") +def na_divest_wf(scenario_workdir): + return _strict(scenario_workdir, "hedge_na_divest") + + +@pytest.fixture(scope="module") +def na_none_wf(scenario_workdir): + return _strict(scenario_workdir, "hedge_na_none") + + +def _resolve(wf): + """In-process re-solve off the snapshot workdir — gives the branch + v_invest / v_divest columns the committed parquet omits.""" + from polar_high import Problem + + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, sol, pb + + +@pytest.fixture(scope="module") +def na_window_resolved(na_window_wf): + return _resolve(na_window_wf) + + +@pytest.fixture(scope="module") +def na_divest_resolved(na_divest_wf): + return _resolve(na_divest_wf) + + +# --- T-Main / T-WS: the hand-calc gate (§9.2, §9.4) ------------------------ + + +@pytest.mark.solver +def test_main_objective_hand_calc(na_window_wf) -> None: + """T-Main (§9.2): the main-mode objective is exactly 173 250 — the + shared first stage costed once at full weight. This is the FIRST test + to fire the cross-Enum invest-NA guard join (§4.2, R3).""" + obj = float( + pl.read_parquet(na_window_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(RP, rel=1e-9) + + +@pytest.mark.solver +def test_main_invest_shared_first_stage(na_window_resolved) -> None: + """T-Main (§9.2): p2035 v_invest is EQUAL across branches (shared + first stage = 60), while p2040 diverges (recourse: r_rlz = 60, + r_low = 0).""" + _data, sol, _pb = na_window_resolved + vi = sol.value("v_invest_p") + got = {r["d"]: round(float(r["value"]), 4) + for r in vi.iter_rows(named=True)} + assert got["p2035"] == pytest.approx(60.0) + assert got["p2035_low"] == pytest.approx(60.0) # shared first stage + assert got["p2035"] == got["p2035_low"] + assert got["p2040"] == pytest.approx(60.0) # recourse (high) + assert got["p2040_low"] == pytest.approx(0.0) # recourse (low) + + +@pytest.mark.solver +def test_ws_control_and_evpi_gap(na_ws_wf, na_window_wf) -> None: + """T-WS (§9.2): the window-unset control is 157 500 (today's + wait-and-see), strictly below RP. The gap RP − WS = 15 750 proves + the tie genuinely shares the first stage (a dropped tie collapses + RP → WS).""" + ws = float( + pl.read_parquet(na_ws_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + rp = float( + pl.read_parquet(na_window_wf / "output_raw" / f"v_obj__{SOLVE}.parquet") + ["objective"][0] + ) + assert ws == pytest.approx(WS, rel=1e-9) + assert rp - ws == pytest.approx(EVPI, rel=1e-9) + assert rp > ws + + +# --- T-Frame / T-Weight / T-C: structural (§9.4) --------------------------- + + +@pytest.mark.solver +def test_frame_window_membership(na_window_resolved) -> None: + """T-Frame (§3.1): pd_non_anticipativity = {(p2035, p2035_low)} — + the window ties only the p2035 anchor's synthetic copy; p2040 is + NOT in the window so (p2040, p2040_low) is absent.""" + data, _sol, _pb = na_window_resolved + pdna = data.pd_non_anticipativity + pairs = {(str(r["d"]), str(r["b"])) + for r in pdna.iter_rows(named=True)} + assert pairs == {("p2035", "p2035_low")} + + +def test_frame_empty_when_window_unset(na_ws_wf) -> None: + """T-Frame / T-P (§6.4): with the window unset the frame is EMPTY + (byte-parity legacy path) — no CSV emitted, tie_periods None.""" + from flextool.engine_polars import load_flextool + + data = load_flextool(na_ws_wf) + pdna = data.pd_non_anticipativity + assert pdna is None or pdna.height == 0 + + +@pytest.mark.solver +def test_branch_weights_partition(na_window_resolved) -> None: + """T-Weight (F7, §9.4): the tied cohort's weights are a probability + partition — pd_branch_weight[p2035] = 0.25, [p2035_low] = 0.75, + summing to 1.0 across the tie (so the shared decision is costed once + at full weight — the Option-B equivalence arithmetic).""" + data, _sol, _pb = na_window_resolved + df = data.pd_branch_weight.frame + val_col = [c for c in df.columns if c != "d"][-1] + w = {str(r["d"]): round(float(r[val_col]), 6) + for r in df.iter_rows(named=True)} + assert w["p2035"] == pytest.approx(0.25) + assert w["p2035_low"] == pytest.approx(0.75) + + +@pytest.mark.solver +def test_invest_constraint_rows(na_window_resolved) -> None: + """T-C (§9.4): non_anticipativity_invest_p ties (base, p2035, + p2035_low); NO row over p2040.""" + _data, _sol, pb = na_window_resolved + names = pb.cstr_names() + assert "non_anticipativity_invest_p" in names + over = pb.cstrs_named("non_anticipativity_invest_p")[0].over + triples = {(str(r["p"]), str(r["d"]), str(r["b"])) + for r in over.iter_rows(named=True)} + assert triples == {("base", "p2035", "p2035_low")} + + +# --- T-Divest (solver, MANDATORY — F2) ------------------------------------- + + +@pytest.mark.solver +def test_divest_family_ties_first_period(na_divest_resolved) -> None: + """T-Divest (§4.4, F2): a divest-eligible base under recourse+window + → non_anticipativity_divest_p ties the first-period divest over + (base, p2035, p2035_low).""" + _data, _sol, pb = na_divest_resolved + names = pb.cstr_names() + assert "non_anticipativity_divest_p" in names + over = pb.cstrs_named("non_anticipativity_divest_p")[0].over + triples = {(str(r["p"]), str(r["d"]), str(r["b"])) + for r in over.iter_rows(named=True)} + assert triples == {("base", "p2035", "p2035_low")} + + +@pytest.mark.solver +def test_divest_net_capacity_equal_across_branches(na_divest_resolved) -> None: + """T-Divest (§4.4): the NET first-stage capacity + (existing + invest − divest) is EQUAL across branches at p2035 — + the tie shares the net decision, not just gross invest.""" + _data, sol, _pb = na_divest_resolved + inv = {r["d"]: float(r["value"]) + for r in sol.value("v_invest_p").iter_rows(named=True)} + div = {r["d"]: float(r["value"]) + for r in sol.value("v_divest_p").iter_rows(named=True)} + existing = 50.0 + net_rlz = existing + inv.get("p2035", 0.0) - div.get("p2035", 0.0) + net_low = existing + inv.get("p2035_low", 0.0) - div.get("p2035_low", 0.0) + assert net_rlz == pytest.approx(net_low, abs=1e-6) + + +# --- T-None (graceful no-op) ----------------------------------------------- + + +@pytest.mark.solver +def test_none_graceful_noop(na_none_wf) -> None: + """T-None (§4.5): the window set on a stochastic_invest_method=none + solve emits ZERO non_anticipativity_invest_* rows (investment is + already shared across all periods — the domain guard / recourse gate + makes it inert).""" + from polar_high import Problem + + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(na_none_wf) + pb = Problem() + build_flextool(pb, data) + invest_na = [n for n in pb.cstr_names() + if "non_anticipativity_invest" in n + or "non_anticipativity_divest" in n] + assert invest_na == [] + + +# --- T-Reg (autoscale) ----------------------------------------------------- + + +def test_registry_has_four_invest_families() -> None: + """T-Reg (§5): all four invest-NA family names are registered in + CONSTRAINT_FAMILIES (a miss silently degrades the solve to an + un-scaled LP — CLAUDE.md invariant 1).""" + from flextool.engine_polars.autoscale._layer2_types import ( + CONSTRAINT_FAMILIES, + ) + + for name in ("non_anticipativity_invest_p", "non_anticipativity_invest_n", + "non_anticipativity_divest_p", "non_anticipativity_divest_n"): + assert name in CONSTRAINT_FAMILIES diff --git a/tests/fixtures/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py index bd3b5a3ed..7a857f281 100644 --- a/tests/fixtures/build_stoch_two_period_hedge.py +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -129,6 +129,12 @@ def build() -> dict: ["stoch", "include_stochastics + branch-varying wind + recourse"], ["rp", "stochastic_branches at p2040 (mid-horizon reveal)"], ["ws", "stochastic_branches at p2035 (wait-and-see / fan-at-first)"], + ["na_window", "invest-NA window = [p2035] + ops free " + "(the standard two-stage mode, v72)"], + ["divest", "base invest_retire_total + existing 50 so v_divest " + "exists (T-Divest: divest tied too, F2)"], + ["invest_none", "stochastic_invest_method=none (T-None: window is " + "a graceful no-op)"], ["storage", "stochastic-group storage node (T-S: storage NA + " "mid-horizon predecessor linkage)"], ] @@ -138,6 +144,15 @@ def build() -> dict: "Wait-and-see fan-at-p2035 control (WS = 157 500)"], ["hedge_storage", False, "Mid-horizon reveal + stochastic-group storage node (T-S)"], + ["hedge_na_window", False, + "Invest-NA window main mode: fan-at-t0 + " + "non_anticipativity_invest_periods=[p2035] (RP = 173 250)"], + ["hedge_na_divest", False, + "T-Divest: divest-eligible base under recourse+window — " + "non_anticipativity_divest_p ties first-period divest too (F2)"], + ["hedge_na_none", False, + "T-None: window set on a stochastic_invest_method=none solve " + "— graceful no-op (no invest-NA rows)"], ] spec["scenario_alternatives"] = [ ["hedge", "init", "base"], @@ -148,6 +163,24 @@ def build() -> dict: ["hedge_ws", "base", "stoch"], ["hedge_ws", "stoch", "ws"], ["hedge_ws", "ws", None], + # Main mode: the fan-at-t0 (ws) topology PLUS the invest-NA window. + ["hedge_na_window", "init", "base"], + ["hedge_na_window", "base", "stoch"], + ["hedge_na_window", "stoch", "ws"], + ["hedge_na_window", "ws", "na_window"], + ["hedge_na_window", "na_window", None], + ["hedge_na_divest", "init", "base"], + ["hedge_na_divest", "base", "stoch"], + ["hedge_na_divest", "stoch", "ws"], + ["hedge_na_divest", "ws", "na_window"], + ["hedge_na_divest", "na_window", "divest"], + ["hedge_na_divest", "divest", None], + ["hedge_na_none", "init", "base"], + ["hedge_na_none", "base", "stoch"], + ["hedge_na_none", "stoch", "ws"], + ["hedge_na_none", "ws", "na_window"], + ["hedge_na_none", "na_window", "invest_none"], + ["hedge_na_none", "invest_none", None], ["hedge_storage", "init", "base"], ["hedge_storage", "base", "stoch"], ["hedge_storage", "stoch", "rp"], @@ -291,6 +324,33 @@ def build() -> dict: # ---- ws: wait-and-see reveal at p2035 -------------------------- ["solve", "stoch_2p_hedge", "stochastic_branches", _pack(stochastic_branches_ws, "map"), "ws"], + # ---- na_window: invest-NA window main mode (v72) --------------- + # Layered on ``ws`` (fan-at-t0 recourse): tie v_invest/v_divest at + # p2035 across branches (shared first stage) while p2040 invests + # per-branch (recourse), and free operations from t0. The tie + # reproduces the Option-B hedge RP = 173 250 by CONSTRAINT (vs the + # mid-horizon ``hedge`` scenario's shared trunk by construction). + ["solve", "stoch_2p_hedge", "non_anticipativity_invest_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), + "na_window"], + ["solve", "stoch_2p_hedge", "non_anticipativity_periods", + _pack({"value_type": "str", "data": []}, "array"), + "na_window"], + # ---- divest: base divest-eligible (T-Divest, F2) --------------- + # invest_retire_total is in BOTH the invest- and divest-allowed + # method sets; a non-zero salvage_value makes the divest annuity + # (eead) structurally non-zero so the entity enters ed_divest and + # v_divest_p exists at every fanned invest period. Existing 50 + # gives it capacity to retire. Under recourse+window the invest + # AND divest ties both fire, so the NET first-stage capacity + # (existing + invest − divest) is shared across branches. + ["unit", "base", "invest_method", + _pack("invest_retire_total", "str"), "divest"], + ["unit", "base", "existing", _pack(50.0, "float"), "divest"], + ["unit", "base", "salvage_value", _pack(1.0, "float"), "divest"], + # ---- invest_none: none composition (T-None) -------------------- + ["solve", "stoch_2p_hedge", "stochastic_invest_method", + _pack("none", "str"), "invest_none"], # ---- storage: a stochastic-group storage node (T-S) ------------ # ``resv`` is a storage node in ``stoch_g`` (via group__node), so # under recourse mid-horizon the dispatch NA net-charge pinning diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json index c5a838b4d..f700e52b2 100644 --- a/tests/fixtures/stoch_two_period_hedge.json +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -990,6 +990,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1213,20 +1227,6 @@ "InllcyI=", "str" ] - ], - [ - "stochastic_invest_methods", - [ - "Im5vbmUi", - "str" - ] - ], - [ - "stochastic_invest_methods", - [ - "InJlY291cnNlIg==", - "str" - ] ] ], "parameter_groups": [ @@ -2246,7 +2246,7 @@ "model", "version", [ - "Njk=", + "NzI=", "float" ], null, @@ -3012,6 +3012,28 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], + [ + "solve", + "non_anticipativity_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "non_anticipativity_periods", + [ + null, + null + ], + null, + "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "period_timeset", @@ -3155,6 +3177,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -3836,17 +3869,6 @@ null, "[per unit / minute] Maximum ramp up speed. Constant.", "tech_advanced" - ], - [ - "solve", - "stochastic_invest_method", - [ - "Im5vbmUi", - "str" - ], - "stochastic_invest_methods", - "How stochastic branches participate in investment (none/recourse).", - "solve_advanced" ] ], "parameter_types": [ @@ -5612,6 +5634,18 @@ "ws", "stochastic_branches at p2035 (wait-and-see / fan-at-first)" ], + [ + "na_window", + "invest-NA window = [p2035] + ops free (the standard two-stage mode, v72)" + ], + [ + "divest", + "base invest_retire_total + existing 50 so v_divest exists (T-Divest: divest tied too, F2)" + ], + [ + "invest_none", + "stochastic_invest_method=none (T-None: window is a graceful no-op)" + ], [ "storage", "stochastic-group storage node (T-S: storage NA + mid-horizon predecessor linkage)" @@ -5632,6 +5666,21 @@ "hedge_storage", false, "Mid-horizon reveal + stochastic-group storage node (T-S)" + ], + [ + "hedge_na_window", + false, + "Invest-NA window main mode: fan-at-t0 + non_anticipativity_invest_periods=[p2035] (RP = 173 250)" + ], + [ + "hedge_na_divest", + false, + "T-Divest: divest-eligible base under recourse+window \u2014 non_anticipativity_divest_p ties first-period divest too (F2)" + ], + [ + "hedge_na_none", + false, + "T-None: window set on a stochastic_invest_method=none solve \u2014 graceful no-op (no invest-NA rows)" ] ], "scenario_alternatives": [ @@ -5675,6 +5724,91 @@ "ws", null ], + [ + "hedge_na_window", + "init", + "base" + ], + [ + "hedge_na_window", + "base", + "stoch" + ], + [ + "hedge_na_window", + "stoch", + "ws" + ], + [ + "hedge_na_window", + "ws", + "na_window" + ], + [ + "hedge_na_window", + "na_window", + null + ], + [ + "hedge_na_divest", + "init", + "base" + ], + [ + "hedge_na_divest", + "base", + "stoch" + ], + [ + "hedge_na_divest", + "stoch", + "ws" + ], + [ + "hedge_na_divest", + "ws", + "na_window" + ], + [ + "hedge_na_divest", + "na_window", + "divest" + ], + [ + "hedge_na_divest", + "divest", + null + ], + [ + "hedge_na_none", + "init", + "base" + ], + [ + "hedge_na_none", + "base", + "stoch" + ], + [ + "hedge_na_none", + "stoch", + "ws" + ], + [ + "hedge_na_none", + "ws", + "na_window" + ], + [ + "hedge_na_none", + "na_window", + "invest_none" + ], + [ + "hedge_na_none", + "invest_none", + null + ], [ "hedge_storage", "init", @@ -6239,6 +6373,66 @@ ], "ws" ], + [ + "solve", + "stoch_2p_hedge", + "non_anticipativity_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "na_window" + ], + [ + "solve", + "stoch_2p_hedge", + "non_anticipativity_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogW119", + "array" + ], + "na_window" + ], + [ + "unit", + "base", + "invest_method", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ], + "divest" + ], + [ + "unit", + "base", + "existing", + [ + "NTAuMA==", + "float" + ], + "divest" + ], + [ + "unit", + "base", + "salvage_value", + [ + "MS4w", + "float" + ], + "divest" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "invest_none" + ], [ "node", "resv", diff --git a/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py b/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py new file mode 100644 index 000000000..90bc246e3 --- /dev/null +++ b/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py @@ -0,0 +1,175 @@ +"""Tests for the v72 database migration — the invest-NA window. + +v72 adds ``solve.non_anticipativity_invest_periods`` — the Array-of-periods +knob for the standard two-stage stochastic-investment mode: the periods +over which ``v_invest`` / ``v_divest`` are TIED across stochastic branches +(a single shared here-and-now decision). + +* unset (default) OR empty Array -> NO tie, byte-identical to prior + behaviour (recourse stays per-branch, none stays shared); +* period list -> tie investment over those periods (shared first stage), + recourse later. + +The parameter is Array-shaped (``default_value`` / ``default_type`` both +``None``, no value list — mirrors ``realized_periods`` / +``non_anticipativity_periods``) and grouped under ``solve_advanced``. +Migrating a DB adds the definition ONLY (no per-solve value), so every +migrated solve resolves to unset -> no tie -> today's behaviour. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from spinedb_api import DatabaseMapping, from_database, import_data + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + + +def test_v72_version_constant_is_at_least_72() -> None: + """The engine must report a schema version >= 72 — the + non_anticipativity_invest_periods lower bound.""" + assert FLEXTOOL_DB_VERSION >= 72 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at the fixture's old version, which + predates v72), migrate it to HEAD, and return an open mapping.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- the migration adds the Array definition (no value list, no default) --- + + +def test_invest_periods_definition_present(tmp_path: Path) -> None: + """``solve.non_anticipativity_invest_periods`` exists as an unset Array + param (no default, no value list) grouped under ``solve_advanced``.""" + db = _migrated_db(tmp_path) + try: + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="non_anticipativity_invest_periods", + ) + assert pdef, "non_anticipativity_invest_periods not added by migration" + assert pdef["default_value"] is None + assert pdef["default_type"] is None + assert not pdef["parameter_value_list_name"] + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_migration_adds_no_per_solve_value(tmp_path: Path) -> None: + """The v72 migration adds the definition ONLY — never a per-solve + value — so every migrated solve resolves to the unset (no-tie) case + and stays byte-identical.""" + db = _migrated_db(tmp_path) + try: + values = [ + v for v in db.get_parameter_value_items() + if v["parameter_definition_name"] + == "non_anticipativity_invest_periods" + ] + assert values == [], ( + "migration must not author any " + "non_anticipativity_invest_periods value" + ) + finally: + db.close() + + +def _build_v71_solve_db(url: str) -> None: + """Create a minimal v71 DB (``model`` + ``solve`` classes) carrying no + ``non_anticipativity_invest_periods`` — so the v72 block is the ONLY + path that can add it. ``model.version`` default pinned to 71 so + ``migrate_database(..., up_to=72)`` runs only the v72 step.""" + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 71.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v71 DB (model + solve, no NA invest periods)") + + +def test_migration_reaches_v72_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v72 DB migrates to exactly v72, and re-running the v72 step + is a no-op that keeps the parameter present.""" + db_path = tmp_path / "v71_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v71_solve_db(url) + start_version = _db_version(url) + assert start_version < 72, ( + "seed DB must start below v72 so the v72 block is exercised" + ) + + migrate_database(url, up_to=72) + assert _db_version(url) == 72 + + # Idempotent re-run of the v72 step. + migrate_database(url, up_to=72) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="non_anticipativity_invest_periods", + ) + assert pdef, "parameter lost on idempotent re-migration" + assert pdef["default_value"] is None + assert pdef["default_type"] is None + + +# --- the committed master template is up to date ------------------------ + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (with the v72 parameter) was committed.""" + assert sync_master_template(verify_only=True) is True From 49c7938d301de9acf4e284ee8b6c4fcc1e133400 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 21:36:59 +0300 Subject: [PATCH 46/87] docs(stochastics): deliver the invest-NA window recipe + changelog Replace the "planned addition" note in how_to.md with the delivered three-knob recipe (recourse + non_anticipativity_invest_periods=[first] + non_anticipativity_periods=[]) and add the CHANGELOG entry for the standard two-stage stochastic-investment mode. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- CHANGELOG.md | 16 +++++++++++++++- docs/how_to.md | 8 +++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4467c8240..3976cf93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ ## Unreleased -Schema change (adds `solve.stochastic_invest_method`; migrated automatically). +Schema change (adds `solve.stochastic_invest_method` and +`solve.non_anticipativity_invest_periods`; migrated automatically). + +- **Standard two-stage stochastic investment (the main mode).** The new + `solve.non_anticipativity_invest_periods` parameter names the periods over + which investment is a single shared *here-and-now* decision: `v_invest` and + `v_divest` are tied across the stochastic branches over those periods, while + later periods invest per branch (recourse). Compose it with + `stochastic_invest_method = recourse` and `non_anticipativity_periods = []` + to get the classic capacity-expansion structure — shared first-period + investment, per-scenario recourse investment thereafter, operations free from + the first timestep. Both invest and divest are tied so the *net* first-stage + capacity is genuinely shared. Left unset (or empty) it adds no tie and every + existing result is byte-identical; on a non-`recourse` solve it is a harmless + no-op. - **Per-scenario stochastic investment** (opt-in `solve.stochastic_invest_method = recourse`): each stochastic branch can now diff --git a/docs/how_to.md b/docs/how_to.md index 42efeb7b8..94e77c6fa 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1225,7 +1225,13 @@ The usual goal is a genuine two-stage plan: **one shared here-and-now investment For a model that invests in a single period this is available today: keep `stochastic_invest_method = none` (one shared investment) and set `non_anticipativity_periods = []` (operations branch freely from t0 — see *What the model ties across branches* above). The result is the classic stochastic capacity-expansion model — commit one fleet, minimise the probability-weighted cost of operating it across the scenarios. -For a **multi-period** investment horizon the ideal is that only the *first* period's investment is shared (the genuine here-and-now decision) while later-period investment adapts per scenario (recourse), since by then some uncertainty has resolved. That refinement — an investment non-anticipativity *window* (investment shared over the first period(s), per-branch after, operations free throughout) — is a planned addition. Until it lands, `none` shares investment across **all** periods (so use it when investment is effectively single-period) and `recourse` (below) shares **none**; neither gives the shared-first-period / recourse-later split on its own. +For a **multi-period** investment horizon the ideal is that only the *first* period's investment is shared (the genuine here-and-now decision) while later-period investment adapts per scenario (recourse), since by then some uncertainty has resolved. This is delivered by the **investment non-anticipativity window** — the `solve` array parameter `non_anticipativity_invest_periods`. Compose three knobs: + +- `stochastic_invest_method = recourse` (so per-branch investment variables exist to tie); +- `non_anticipativity_invest_periods = []` (tie the first period's `v_invest`/`v_divest` across branches — a single shared here-and-now decision — while later periods invest per-branch); +- `non_anticipativity_periods = []` (operations branch freely from t0). + +The result is the standard two-stage stochastic capacity-expansion structure: **shared first-period investment + per-branch (recourse) investment later + free operations**. Set `non_anticipativity_invest_periods` to more than one period to extend the shared window (e.g. `[p2035, p2040]` shares the first two periods, recourse from the third). Left unset (the default) or set to an empty array it adds no tie — `recourse` stays per-branch everywhere, byte-identical to before. Both invest and divest are tied over the window, so the *net* first-stage capacity (existing + invest − divest) is genuinely shared. The window requires `recourse`; on a `none` solve it is a harmless no-op (investment is already shared across all periods). #### Per-scenario (wait-and-see) investment From 4202ac9ccb24445d91642dc089be9bc85e191637 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 22:04:09 +0300 Subject: [PATCH 47/87] refactor(stochastic): drop dead ctx branch in invest-NA window reader Code-review nit: _read_invest_tie_periods had a speculative ctx-first branch that nothing populates and that would silently iterate solve-name keys if a future caller wired the {solve: [...]} config. The emitted CSV is authoritative; removed the branch, documented why ctx is not a source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_branch.py | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 7b32152a5..37bb8a7e4 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1171,20 +1171,19 @@ def _read_invest_tie_periods( """Resolve the v72 invest-NA window (the periods to tie) for this solve. Returns ``None`` when the window is unset (no - ``pd_non_anticipativity_invest_periods.csv`` was emitted and ctx - carries nothing) — the byte-parity legacy path. Returns a - ``frozenset`` of period tokens otherwise (possibly empty, though the - emitter only writes the CSV for a non-empty window). - - ctx-first (``SolveContext.non_anticipativity_invest_periods`` — a - list of period tokens or ``(p, p)`` tuples), then the per-solve CSV - materialised by ``_emit_per_solve.emit_per_solve_sets``. + ``pd_non_anticipativity_invest_periods.csv`` was emitted) — the + byte-parity legacy path. Returns a ``frozenset`` of period tokens + otherwise (possibly empty, though the emitter only writes the CSV + for a non-empty window). + + The per-solve CSV materialised by + ``_emit_per_solve.emit_per_solve_sets`` is authoritative and requires + a ``provider``. The ``ctx`` argument is accepted for signature parity + with sibling readers but is deliberately NOT a source for this window: + ``SolveConfig.non_anticipativity_invest_periods`` is a + ``{solve: [...]}`` mapping, so reading it off ctx would silently + iterate solve-name keys — the CSV avoids that footgun. """ - if ctx is not None: - w = getattr(ctx, "non_anticipativity_invest_periods", None) - if w is not None: - return frozenset( - str(p[0] if isinstance(p, (tuple, list)) else p) for p in w) if workdir is None: return None p = Path(workdir) / "solve_data" / "pd_non_anticipativity_invest_periods.csv" From 407ce0969a2682c5c0eca6bd83d874cd001a070e Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:00:22 +0300 Subject: [PATCH 48/87] feat(invest): construction lead time schema (v73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice H P0 — the schema flag, no behaviour change yet. Two per-entity parameters on unit/connection/node: * construction_lead_time — [years] float/map, default unset (0), autoscale DURATION; * construction_lead_time_method — enum immediate/closest_seam/ previous_seam/next_seam bound to the construction_lead_time_methods value list, default null (so the reader can tell unset from explicit immediate for the two-level default), autoscale DIMENSIONLESS. Migration adds definitions ONLY (never a per-entity value), so every migrated model resolves to unset -> L=0 -> immediate -> byte-identical. construction_lead_time is stored with explicit float@0 / map@1 parameter_type ranks (the parameter_type_list convenience arg collapses both to rank 0 in spinedb_api, dropping the map row on re-import). export_settings: construction_lead_time -> period_only_params on node/unit/connection (next to lifetime); method goes nowhere (scalar string, like lifetime_method). No new autoscale variable/constraint family. Regenerated spinedb_schema.json + canonical_databases + fixtures; all four CI schema-sync verify steps green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../autoscale/_quantity_types.py | 12 + .../export_to_tabular/export_settings.yaml | 3 + .../howto_aggregate_output.json | 132 +++++++++- .../howto_connections.json | 132 +++++++++- .../canonical_databases/howto_demand.json | 132 +++++++++- .../howto_hydro_reservoir.json | 132 +++++++++- .../howto_hydro_reservoir_with_pump.json | 132 +++++++++- .../howto_non_sync_and_curtailment.json | 132 +++++++++- .../howto_ramp_and_start_up.json | 132 +++++++++- .../howto_stochastics.json | 132 +++++++++- .../templates_examples.json | 132 +++++++++- .../templates_time_settings_only.json | 132 +++++++++- flextool/schemas/spinedb_schema.json | 120 ++++++++- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 111 ++++++++ tests/fixtures/branch2_parent_period.json | 150 ++++++++++- tests/fixtures/case14_dc_power_flow.json | 150 ++++++++++- tests/fixtures/h2_trade_parity.json | 150 ++++++++++- tests/fixtures/lh2_three_region.json | 150 ++++++++++- tests/fixtures/stochastics.json | 150 ++++++++++- tests/fixtures/stochastics_pbt_inflow.json | 150 ++++++++++- tests/fixtures/tests.json | 150 ++++++++++- .../test_v73_construction_lead_time.py | 247 ++++++++++++++++++ 23 files changed, 2846 insertions(+), 19 deletions(-) create mode 100644 tests/update_flextool/test_v73_construction_lead_time.py diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 184dd0ccb..4af0dab7a 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -88,6 +88,18 @@ class QuantityType(Enum): # Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total ('co2_price', 'group'): QuantityType.PRICE_PER_MASS, # [CUR/ton] CO2 price for a group of nodes. Constant, period or time. + ('construction_lead_time', 'connection'): QuantityType.DURATION, + # [years] Construction lead time: capacity ordered in a period becomes available this many years later (snapped to a period boundary). Constant or period. + ('construction_lead_time', 'node'): QuantityType.DURATION, + # [years] Construction lead time: capacity ordered in a period becomes available this many years later (snapped to a period boundary). Constant or period. + ('construction_lead_time', 'unit'): QuantityType.DURATION, + # [years] Construction lead time: capacity ordered in a period becomes available this many years later (snapped to a period boundary). Constant or period. + ('construction_lead_time_method', 'connection'): QuantityType.DIMENSIONLESS, + # How yr(order)+construction_lead_time snaps to a period boundary (immediate/closest_seam/previous_seam/next_seam) — an enum string, no physical unit. + ('construction_lead_time_method', 'node'): QuantityType.DIMENSIONLESS, + # How yr(order)+construction_lead_time snaps to a period boundary (immediate/closest_seam/previous_seam/next_seam) — an enum string, no physical unit. + ('construction_lead_time_method', 'unit'): QuantityType.DIMENSIONLESS, + # How yr(order)+construction_lead_time snaps to a period boundary (immediate/closest_seam/previous_seam/next_seam) — an enum string, no physical unit. ('debug', 'model'): QuantityType.DIMENSIONLESS, # Instruction set for performing model debugging and testing — a string toggle, no physical unit. ('constant', 'constraint'): QuantityType.DIMENSIONLESS, diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 733752f12..3022b4727 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -319,6 +319,7 @@ period_only_params: - invest_max_period - invest_min_period - lifetime + - construction_lead_time - peak_inflow - retire_max_period - retire_min_period @@ -340,6 +341,7 @@ period_only_params: - invest_max_period - invest_min_period - lifetime + - construction_lead_time - retire_max_period - retire_min_period - salvage_value @@ -355,6 +357,7 @@ period_only_params: - invest_max_period - invest_min_period - lifetime + - construction_lead_time - retire_max_period - retire_min_period - salvage_value diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index 326a94f3e..fabdaa367 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -906,6 +906,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1578,7 +1594,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1628,6 +1644,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2417,6 +2449,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3008,6 +3056,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3656,6 +3722,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -4292,6 +4376,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4754,6 +4856,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index 1f8cd67b2..2a7c221fc 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -541,6 +541,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1213,7 +1229,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1263,6 +1279,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2055,6 +2087,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2662,6 +2710,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3310,6 +3376,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3946,6 +4030,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4408,6 +4510,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index 778bc42f5..d618f4c15 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -457,6 +457,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1129,7 +1145,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1179,6 +1195,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -1971,6 +2003,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2578,6 +2626,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3226,6 +3292,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3862,6 +3946,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4324,6 +4426,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index e44f5cdb3..45597e689 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -710,6 +710,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1382,7 +1398,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1432,6 +1448,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2224,6 +2256,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2831,6 +2879,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3479,6 +3545,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -4115,6 +4199,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4577,6 +4679,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index 7b828c950..1c1d5c5b0 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -559,6 +559,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1231,7 +1247,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1281,6 +1297,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2073,6 +2105,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2680,6 +2728,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3328,6 +3394,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3964,6 +4048,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4426,6 +4528,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index 9bdabd5a4..f1ba6e207 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -588,6 +588,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1260,7 +1276,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1310,6 +1326,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2091,6 +2123,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2698,6 +2746,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3346,6 +3412,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3982,6 +4066,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4444,6 +4546,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index db5cbdbef..058f9fb71 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -528,6 +528,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1200,7 +1216,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1250,6 +1266,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2042,6 +2074,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2649,6 +2697,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3297,6 +3363,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3933,6 +4017,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4395,6 +4497,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index 55b187f35..b07a2aaa8 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -658,6 +658,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1321,7 +1337,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1371,6 +1387,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2163,6 +2195,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2748,6 +2796,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3396,6 +3462,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -4032,6 +4116,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4494,6 +4596,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index 38a7ca41c..bd189c1cd 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -1843,6 +1843,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the connection capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2503,7 +2519,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -2553,6 +2569,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -3334,6 +3366,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the unit capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3925,6 +3973,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4573,6 +4639,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5221,6 +5305,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -5707,6 +5809,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index 7dd858b4b..7332f8a37 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -407,6 +407,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1079,7 +1095,7 @@ "model", "version", [ - "72", + "73", "float" ], null, @@ -1129,6 +1145,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -1918,6 +1950,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2509,6 +2557,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3157,6 +3223,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -3793,6 +3877,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -4255,6 +4357,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "\"immediate\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"closest_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"previous_seam\"", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "\"next_seam\"", + "str" + ] + ], [ "conversion_methods", [ diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index 6ec5bbcf3..9a61f0416 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -321,6 +321,22 @@ "co2_methods", "price_period_total" ], + [ + "construction_lead_time_methods", + "immediate" + ], + [ + "construction_lead_time_methods", + "closest_seam" + ], + [ + "construction_lead_time_methods", + "previous_seam" + ], + [ + "construction_lead_time_methods", + "next_seam" + ], [ "conversion_methods", "constant_efficiency" @@ -1000,6 +1016,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -1611,7 +1643,7 @@ [ "model", "version", - 72.0, + 73.0, null, "Contains database version information.", "model" @@ -1656,6 +1688,22 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2368,6 +2416,22 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] — new-build capacity decided in the current period — on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + null, + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + null, + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -2864,6 +2928,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -3542,6 +3624,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -4232,6 +4332,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index 2e2154153..6e4b39b53 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 72 +FLEXTOOL_DB_VERSION: int = 73 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 82e336605..ce6783408 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1767,6 +1767,8 @@ def _is_yes(pv): _migrate_v71_non_anticipativity_periods(db) elif next_version == 72: _migrate_v72_non_anticipativity_invest_periods(db) + elif next_version == 73: + _migrate_v73_construction_lead_time(db) else: print("Version invalid") last_completed_version = next_version @@ -3992,6 +3994,115 @@ def _migrate_v72_non_anticipativity_invest_periods(db) -> None: ) +def _migrate_v73_construction_lead_time(db) -> None: + """Add construction_lead_time + _method per invest entity (v72 -> v73). + + Slice H — construction lead time. Two per-entity parameters on each + invest entity class (``unit`` / ``connection`` / ``node``): + + * ``construction_lead_time`` — ``[years]`` physical build time. + Capacity ordered in a period becomes available this many years + later, snapped to a period boundary per + ``construction_lead_time_method``. Constant or period (float / map, + mirroring ``lifetime``). Default 0 (instantaneous, as before). + * ``construction_lead_time_method`` — how ``yr(order) + + construction_lead_time`` snaps to a period boundary: ``immediate`` + (no lag; capacity from the decision period; byte-identical to prior + behaviour), ``closest_seam`` (nearest boundary, exact tie rounds up; + the effective default when a lead time is set), ``previous_seam`` + (round down), ``next_seam`` (round up). + + Both defaults are ``null`` (see design §0.2 / §6.2): the *unset* vs + explicit-``immediate`` distinction is what lets the reader implement + the two-level default (unset resolves to ``immediate`` when the lead + time is 0, else ``closest_seam``). A literal ``immediate`` default + would materialise on every entity and destroy that distinction. + + Migrating a DB adds only the definitions (never a per-entity value), + so every migrated entity resolves to unset -> ``L=0`` -> ``immediate`` + -> byte-identical to prior behaviour. Grouped under ``investment`` + (mirrors ``lifetime``; created if the DB lacks it). + """ + add_value_list_manual(db, [ + ["construction_lead_time_methods", "immediate"], + ["construction_lead_time_methods", "closest_seam"], + ["construction_lead_time_methods", "previous_seam"], + ["construction_lead_time_methods", "next_seam"], + ]) + + lead_desc = ( + "[years] Construction lead time: capacity ordered in a period " + "becomes available this many years later, snapped to a period " + "boundary per construction_lead_time_method (defaults to " + "closest_seam when set; use immediate to keep capacity available " + "from the decision period). Constant or period. Default 0 " + "(instantaneous, as before)." + ) + method_desc = ( + "How yr(order)+construction_lead_time snaps to a period boundary: " + "immediate (no lag; capacity from the decision period; " + "byte-identical to prior behaviour), closest_seam (nearest " + "boundary, exact tie rounds up; the effective default when a lead " + "time is set), previous_seam (round down), next_seam (round up). " + "Unset resolves to immediate when no lead time, else closest_seam." + ) + # ``investment`` group already carries lifetime on these classes; real + # DBs have it (created pre-v44). Minimal fixtures seeded above that + # version carry no groups — get-or-create (mirrors v70/v72's + # solve_advanced rationale), using the schema colour/priority for + # ``investment`` (7fb095 / 20). + if not db.get_item("parameter_group", name="investment"): + db.add_update_item( + "parameter_group", + name="investment", + color="7fb095", + priority=20, + ) + for class_name in ("unit", "connection", "node"): + db.add_update_item( + "parameter_definition", + entity_class_name=class_name, + name="construction_lead_time", + default_value=None, + default_type=None, + parameter_group_name="investment", + description=lead_desc, + ) + # ``parameter_type_list=("float","map")`` assigns BOTH types rank 0 + # in spinedb_api (a quirk of the convenience arg — the map row then + # collides with float on the unique (definition, rank) key and is + # dropped on re-import). Add the two allowed types as explicit + # ``parameter_type`` items with distinct ranks (float 0, map 1), + # matching how ``lifetime`` is stored. + for rank, type_name in enumerate(("float", "map")): + db.add_update_item( + "parameter_type", + entity_class_name=class_name, + parameter_definition_name="construction_lead_time", + type=type_name, + rank=rank, + ) + db.add_update_item( + "parameter_definition", + entity_class_name=class_name, + name="construction_lead_time_method", + default_value=None, + default_type=None, + parameter_type_list=("str",), + parameter_value_list_name="construction_lead_time_methods", + parameter_group_name="investment", + description=method_desc, + ) + + _commit_step(db, + "v73: added construction_lead_time (float/map, [years], default 0) " + "and construction_lead_time_method (immediate/closest_seam/" + "previous_seam/next_seam, default unset) on unit/connection/node; " + "Slice H construction lead time — commissioning lag lives in the " + "availability walk, cost stays at the order period." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 615510019..203132dab 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -620,6 +620,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -1687,6 +1715,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2527,7 +2577,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -2589,6 +2639,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -3579,6 +3651,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -4219,6 +4313,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4867,6 +4979,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5503,6 +5633,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index 8b8430a13..2dfaa89b6 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -1658,6 +1658,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -2739,6 +2767,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -3579,7 +3629,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -3641,6 +3691,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -4620,6 +4692,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -5260,6 +5354,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -5908,6 +6020,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -6544,6 +6674,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index 99145a099..f3c47162e 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -6766,6 +6766,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -7833,6 +7861,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -8673,7 +8723,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -8735,6 +8785,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -9703,6 +9775,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -10343,6 +10437,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -10991,6 +11103,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -11621,6 +11751,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index 6ae657595..4adee5125 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -2663,6 +2663,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -3681,6 +3709,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the connection capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -4521,7 +4571,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -4583,6 +4633,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -5562,6 +5634,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the unit capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -6208,6 +6302,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -6856,6 +6968,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -7504,6 +7634,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index 05cadb3cb..7bab108c5 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -620,6 +620,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -1687,6 +1715,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2527,7 +2577,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -2589,6 +2639,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -3579,6 +3651,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -4219,6 +4313,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4867,6 +4979,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5503,6 +5633,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index d606bb00f..a0c0c8a5c 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -620,6 +620,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -1687,6 +1715,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2527,7 +2577,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -2589,6 +2639,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -3579,6 +3651,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -4219,6 +4313,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4867,6 +4979,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5503,6 +5633,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index e6c550e57..38fb921a8 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -1609,6 +1609,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -2627,6 +2655,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the connection capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -3467,7 +3517,7 @@ "model", "version", [ - "NzI=", + "NzM=", "float" ], null, @@ -3529,6 +3579,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -4508,6 +4580,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the unit capacity in user-defined constraints. [(invest - divest variable) x coefficient] will be on the left side of the equation. Invest and divest variables are not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -5154,6 +5248,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -5802,6 +5914,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -6450,6 +6580,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", diff --git a/tests/update_flextool/test_v73_construction_lead_time.py b/tests/update_flextool/test_v73_construction_lead_time.py new file mode 100644 index 000000000..4dddf2112 --- /dev/null +++ b/tests/update_flextool/test_v73_construction_lead_time.py @@ -0,0 +1,247 @@ +"""Tests for the v73 database migration — construction lead time (Slice H). + +v73 adds two per-entity parameters on each invest entity class +(``unit`` / ``connection`` / ``node``): + +* ``construction_lead_time`` — ``[years]`` float/map, default unset (0); +* ``construction_lead_time_method`` — enum + (``immediate`` / ``closest_seam`` / ``previous_seam`` / ``next_seam``), + bound to the ``construction_lead_time_methods`` value list, default unset + (null — so the reader can distinguish unset from explicit ``immediate``). + +Both defaults are ``null`` and the migration adds definitions ONLY (never a +per-entity value), so every migrated entity resolves to unset -> ``L=0`` -> +``immediate`` -> byte-identical to prior behaviour. Grouped under +``investment`` (mirrors ``lifetime``). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from spinedb_api import DatabaseMapping, from_database, import_data + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + +_CLASSES = ("unit", "connection", "node") +_METHODS = ("immediate", "closest_seam", "previous_seam", "next_seam") + + +def test_v73_version_constant_is_at_least_73() -> None: + """The engine must report a schema version >= 73 — the + construction_lead_time lower bound.""" + assert FLEXTOOL_DB_VERSION >= 73 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at its old version, predating v73), + migrate it to HEAD, and return an open mapping.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- definitions on all three invest classes --------------------------- + + +def test_lead_time_definition_present(tmp_path: Path) -> None: + """``construction_lead_time`` exists as an unset float/map param + (no default, no value list) grouped under ``investment`` on every + invest entity class.""" + db = _migrated_db(tmp_path) + try: + for cls in _CLASSES: + pdef = db.get_parameter_definition_item( + entity_class_name=cls, name="construction_lead_time", + ) + assert pdef, f"construction_lead_time missing on {cls}" + assert pdef["default_value"] is None + assert pdef["default_type"] is None + assert not pdef["parameter_value_list_name"] + assert pdef["parameter_group_name"] == "investment" + finally: + db.close() + + +def test_method_definition_present(tmp_path: Path) -> None: + """``construction_lead_time_method`` exists as an unset enum param + (null default so unset != explicit immediate) bound to the + ``construction_lead_time_methods`` value list, grouped under + ``investment`` on every invest entity class.""" + db = _migrated_db(tmp_path) + try: + for cls in _CLASSES: + pdef = db.get_parameter_definition_item( + entity_class_name=cls, name="construction_lead_time_method", + ) + assert pdef, f"construction_lead_time_method missing on {cls}" + assert pdef["default_value"] is None, ( + "method default must be null, not literal 'immediate' — the " + "reader needs unset != explicit immediate for the two-level " + "default" + ) + assert pdef["default_type"] is None + assert (pdef["parameter_value_list_name"] + == "construction_lead_time_methods") + assert pdef["parameter_group_name"] == "investment" + finally: + db.close() + + +def test_method_value_list_values(tmp_path: Path) -> None: + """The value list carries exactly the four methods.""" + db = _migrated_db(tmp_path) + try: + got = { + from_database(v["value"], v["type"]) + for v in db.get_list_value_items() + if v["parameter_value_list_name"] + == "construction_lead_time_methods" + } + assert got == set(_METHODS), f"value list mismatch: {got}" + finally: + db.close() + + +def test_lead_time_parameter_types_ranked(tmp_path: Path) -> None: + """``construction_lead_time`` allows float (rank 0) + map (rank 1) with + DISTINCT ranks on every class — a collapsed rank silently drops the + map row on re-import.""" + db = _migrated_db(tmp_path) + try: + for cls in _CLASSES: + types = { + (t["type"], t["rank"]) + for t in db.get_parameter_type_items() + if t["entity_class_name"] == cls + and t["parameter_definition_name"] == "construction_lead_time" + } + assert ("float", 0) in types, f"{cls}: float@0 missing {types}" + assert ("map", 1) in types, f"{cls}: map@1 missing {types}" + ranks = [r for _, r in types] + assert len(ranks) == len(set(ranks)), ( + f"{cls}: duplicate parameter_type ranks {types}" + ) + finally: + db.close() + + +def test_migration_adds_no_per_entity_value(tmp_path: Path) -> None: + """The v73 migration adds definitions ONLY — never a per-entity value — + so every migrated entity resolves to unset -> immediate -> byte-parity.""" + db = _migrated_db(tmp_path) + try: + values = [ + v for v in db.get_parameter_value_items() + if v["parameter_definition_name"] in ( + "construction_lead_time", "construction_lead_time_method") + ] + assert values == [], ( + "migration must not author any construction_lead_time value" + ) + finally: + db.close() + + +def _build_v72_invest_db(url: str) -> None: + """Create a minimal v72 DB with the three invest entity classes carrying + no construction_lead_time — so the v73 block is the ONLY path that can + add it. ``model.version`` default pinned to 72.""" + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["unit", ()], + ["connection", ()], + ["node", ()], + ], + parameter_definitions=[ + ["model", "version", 72.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["unit", "u1"], + ["node", "n1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v72 DB (invest classes, no lead time)") + + +def test_migration_reaches_v73_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v73 DB migrates to exactly v73, and re-running the v73 step is + a no-op that keeps the parameters present with distinct type ranks.""" + db_path = tmp_path / "v72_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v72_invest_db(url) + start_version = _db_version(url) + assert start_version < 73, ( + "seed DB must start below v73 so the v73 block is exercised" + ) + + migrate_database(url, up_to=73) + assert _db_version(url) == 73 + + # Idempotent re-run of the v73 step. + migrate_database(url, up_to=73) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + for cls in _CLASSES: + pdef = db.get_parameter_definition_item( + entity_class_name=cls, name="construction_lead_time", + ) + assert pdef, f"lead_time lost on idempotent re-migration ({cls})" + types = { + (t["type"], t["rank"]) + for t in db.get_parameter_type_items() + if t["entity_class_name"] == cls + and t["parameter_definition_name"] == "construction_lead_time" + } + assert ("float", 0) in types and ("map", 1) in types, ( + f"{cls}: type ranks corrupted after idempotent re-run {types}" + ) + + +# --- the committed master template is up to date ------------------------ + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (with the v73 parameters) was committed.""" + assert sync_master_template(verify_only=True) is True From 08952d8f16b2c72972205912194dafac5e64cc95 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:04:09 +0300 Subject: [PATCH 49/87] feat(invest): commissioning-year helper for construction lead time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice H P1 — commissioning_year_lf + _construction_method_with_default_lf in _derived_existing.py. Solver-free; no walk change yet. commissioning_year_lf(source, active_solve, anchor_ed_lf, period_in_use, *, workdir, provider) -> [e, d, yr_c] | None computes c = snap(yr(o) + L, method) over the solve's period-boundary seam set: * previous_seam -> last seam <= t*; next_seam -> first seam >= t* (+inf sentinel when none, so out-of-horizon orders buy no capacity — OQ-7); * closest_seam -> nearer seam, exact tie -> the later (conservative) seam, snaps to the last seam when t* is beyond the horizon. Seam years are read via _p_years_d_lf through the IDENTICAL provider/workdir fallback block period_walk_iterator uses (mirrored verbatim), so under recourse a branch order period's yr(o) lands on the same calendar the walk compares yr_dall against (F2 calendar identity). Two-level default: unset method resolves to immediate when L=0, else closest_seam; explicit always wins (per-row, so period-varying L Maps resolve correctly). immediate rows and any snap landing on the order period (L=0 included) are dropped -> None when empty -> byte-parity by omission. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_existing.py | 163 ++++++++++++++++++ .../test_construction_lead_time_helper.py | 150 ++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 tests/engine_polars/test_construction_lead_time_helper.py diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index 49f53475f..38a0299fd 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -310,6 +310,169 @@ def _resolve_per_period_lf(per_param: pl.LazyFrame, .drop("v_explicit", "v_scalar")) +# --------------------------------------------------------------------------- +# Slice H — construction lead time: commissioning-year helper +# --------------------------------------------------------------------------- + +# Two-level default: an entity with a lead time but no explicit method +# commissions at the closest seam; with no lead time it is immediate. +_CONSTRUCTION_METHOD_DEFAULT_LAG = "closest_seam" +_CONSTRUCTION_METHOD_IMMEDIATE = "immediate" + + +def _construction_method_with_default_lf(source: "InputSource", + lead_lf: pl.LazyFrame, + ) -> pl.LazyFrame: + """Lazy ``[e, d, L, method]`` — resolve ``construction_lead_time_method`` + with the two-level default (Slice H design §2.4 step 2 / §6.2). + + ``lead_lf`` is the per-(e, d) resolved lead time ``[e, d, L]``. An + explicit method (per entity) always wins. Where unset, the method + resolves to ``immediate`` when ``L == 0`` and ``closest_seam`` when + ``L > 0`` — so existing (L=0) models stay byte-identical while a set + lead time enables the lag without a mandatory method entry. The + resolution is per-row because ``L`` may be period-varying (a Map). + """ + explicit = (_entity_method_lf(source, "construction_lead_time_method") + .filter(pl.col("method").is_not_null()) + .unique(subset=["e"])) + # Align the explicit-method ``e`` (String from CSV) to lead_lf.e's + # dtype (may be Enum on the post-load path) before the join. + e_dt = lead_lf.collect_schema().get("e", pl.Utf8) + if e_dt != pl.Utf8: + explicit = explicit.with_columns(pl.col("e").cast(e_dt, strict=False)) + return (lead_lf + .join(explicit, on="e", how="left") + .with_columns( + method=pl.when(pl.col("method").is_not_null()) + .then(pl.col("method")) + .when(pl.col("L") > 0.0) + .then(pl.lit(_CONSTRUCTION_METHOD_DEFAULT_LAG)) + .otherwise(pl.lit(_CONSTRUCTION_METHOD_IMMEDIATE)), + )) + + +def commissioning_year_lf(source: "InputSource", + active_solve: str | None, + anchor_ed_lf: pl.LazyFrame, + period_in_use: list[str], + *, + workdir: Path | None = None, + provider: "object | None" = None, + ) -> pl.LazyFrame | None: + """Lazy ``[e, d, yr_c]`` — the commissioning year ``c = snap(yr(o)+L)`` + per (entity, order-period ``o``), or ``None`` when no entity has a + lag-active method (Slice H design §2.4). + + ``d`` is the order period ``o``. ``yr_c`` is the cumulative-year + coordinate of the seam the ordered capacity commissions on, snapped per + ``construction_lead_time_method``: + + * ``previous_seam`` — last seam ``<= t*``; + * ``next_seam`` — first seam ``>= t*`` (``+inf`` if none, i.e. the + order buys no in-horizon capacity — OQ-7); + * ``closest_seam`` — nearer of the two, exact tie -> the later seam; + snaps to the last seam when ``t*`` is beyond the horizon. + + where ``t* = yr(o) + L``. Rows resolving to ``immediate`` (or where the + snapped ``yr_c`` equals ``yr(o)`` — e.g. ``L == 0``) are dropped: they + must not perturb the availability walk (the walk keeps ``yr_d`` for any + (e, d) absent from this frame, giving byte-parity). Returns ``None`` if + the resulting frame is empty. + + **[F2] Seam-calendar identity under recourse.** The seam years are + read from :func:`._derived_params._p_years_d_lf` through the *identical* + provider/workdir resolution :func:`._derived_walks.period_walk_iterator` + uses (the Slice-D fallback block), so that under recourse a branch order + period's ``yr(o)`` lands on the same calendar the walk compares + ``yr_dall`` against. Reproduced verbatim below. + """ + if not period_in_use: + return None + # [F2] Mirror period_walk_iterator's provider/workdir fallback block + # (_derived_walks.py:269-276) VERBATIM so this helper's _p_years_d_lf + # call hits the identical calendar as the walk's yr_dall. + if provider is None: + from flextool.engine_polars._derived_params import ( + _recourse_walk_provider, + _recourse_walk_workdir, + ) + provider = _recourse_walk_provider() + if provider is not None and workdir is None: + workdir = _recourse_walk_workdir() + + from ._derived_params import _p_years_d_lf + pyd_lf = _p_years_d_lf(source, active_solve, workdir, provider=provider) + if pyd_lf is None: + return None + + # Per-(e, o) lead time over the anchor grid, then resolve the method. + lead_lf = (_resolve_per_period_lf( + _per_entity_param_lf(source, "construction_lead_time"), + anchor_ed_lf, fill=0.0) + .rename({"value": "L"}) + .select("e", "d", "L")) + lead_lf = _construction_method_with_default_lf(source, lead_lf) + + # Order-period year yr(o). Align pyd.d to the anchor d dtype and + # fill_null(0.0) exactly as the walk does for yr_d (calendar identity). + d_dt = anchor_ed_lf.collect_schema().get("d", pl.Utf8) + pyd_d = pyd_lf.rename({"yr": "yr_o"}) + if d_dt != pl.Utf8: + pyd_d = pyd_d.with_columns(pl.col("d").cast(d_dt, strict=False)) + work = (lead_lf + .join(pyd_d, on="d", how="left") + .with_columns(pl.col("yr_o").fill_null(0.0))) + + # Seam set S = distinct period-boundary years (branch-invariant). + seams = (pyd_lf.select(pl.col("yr").alias("seam")) + .unique() + .sort("seam")) + + inf = float("inf") + work = (work + .with_columns(t_star=pl.col("yr_o") + pl.col("L")) + .sort("t_star")) + # lo = last seam <= t* (always exists: yr(o) <= t*, yr(o) in S). + work = work.join_asof( + seams.rename({"seam": "lo"}), + left_on="t_star", right_on="lo", strategy="backward") + # hi = first seam >= t* (null when t* is beyond the last seam). + work = work.join_asof( + seams.rename({"seam": "hi"}), + left_on="t_star", right_on="hi", strategy="forward") + + closest = (pl.when(pl.col("hi").is_null()) + .then(pl.col("lo")) + .when((pl.col("t_star") - pl.col("lo")) + < (pl.col("hi") - pl.col("t_star"))) + .then(pl.col("lo")) + .otherwise(pl.col("hi"))) + next_seam = (pl.when(pl.col("hi").is_null()) + .then(pl.lit(inf)) + .otherwise(pl.col("hi"))) + yr_c = (pl.when(pl.col("method") == "previous_seam") + .then(pl.col("lo")) + .when(pl.col("method") == "next_seam") + .then(next_seam) + .when(pl.col("method") == "closest_seam") + .then(closest) + .otherwise(pl.col("yr_o"))) # immediate / unknown -> no lag + + out = (work + .with_columns(yr_c=yr_c.cast(pl.Float64)) + # Drop immediate rows and any snap that lands on the order + # period (L=0, or a snap back to yr(o)) — they must not + # perturb the walk (byte-parity by omission). + .filter((pl.col("method") != "immediate") + & (pl.col("yr_c") != pl.col("yr_o"))) + .select("e", "d", "yr_c")) + + if out.collect().height == 0: + return None + return out + + # --------------------------------------------------------------------------- # §3.7.0 — entityInvest / entityDivest / e_invest_total / e_divest_total # --------------------------------------------------------------------------- diff --git a/tests/engine_polars/test_construction_lead_time_helper.py b/tests/engine_polars/test_construction_lead_time_helper.py new file mode 100644 index 000000000..e4037439b --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_helper.py @@ -0,0 +1,150 @@ +"""Slice H — construction lead time: commissioning-year helper (solver-free). + +Drives :func:`._derived_existing.commissioning_year_lf` directly against a +hand-built stub source on the design §2.3 uneven calendar (seams +``S = {0, 5, 8, 20}``), pinning ``c = snap(yr(o) + L, method)`` for every +method (§7d) and the two-level default (§7e). + +The stub feeds the seam calendar through ``solve.years_from_start`` +(``_p_years_d_lf`` arm 3), exactly like ``test_period_walk_lineage``. +""" +from __future__ import annotations + +import math + +import polars as pl +import pytest + +from flextool.engine_polars._derived_existing import commissioning_year_lf + +# §2.3 calendar: p1 [0,5), p2 [5,8), p3 [8,20), p4 [20, tail). +PERIODS = ["p1", "p2", "p3", "p4"] +YEARS = [0.0, 5.0, 8.0, 20.0] + + +class _StubSource: + """Answers ``parameter(entity_class, name)`` from a dict; ``KeyError`` + otherwise (mirrors ``test_period_walk_lineage._StubSource``).""" + + def __init__(self, params: dict[tuple[str, str], pl.DataFrame]): + self._params = params + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _years_df() -> pl.DataFrame: + return pl.DataFrame({ + "name": ["s"] * len(PERIODS), + "period": PERIODS, + "value": YEARS, + }) + + +def _source(lead: float | None, method: str | None, + entity: str = "nuke", ec: str = "unit") -> _StubSource: + params: dict[tuple[str, str], pl.DataFrame] = { + ("solve", "years_from_start"): _years_df(), + } + if lead is not None: + params[(ec, "construction_lead_time")] = pl.DataFrame( + {"name": [entity], "value": [float(lead)]}) + if method is not None: + params[(ec, "construction_lead_time_method")] = pl.DataFrame( + {"name": [entity], "value": [method]}) + return _StubSource(params) + + +def _anchor(order_period: str, entity: str = "nuke") -> pl.LazyFrame: + return pl.LazyFrame({"e": [entity], "d": [order_period]}) + + +def _yr_c(lead, method, order_period): + """Return the single resolved yr_c, or ``None`` when the frame is + empty (helper returned None).""" + src = _source(lead, method) + out = commissioning_year_lf(src, "s", _anchor(order_period), PERIODS) + if out is None: + return None + df = out.collect() + assert df.height == 1, f"expected one row, got {df.height}" + return df["yr_c"][0] + + +# --- §7d seam-snap, per method, on the uneven calendar ----------------- + +@pytest.mark.parametrize("lead,method,order,expected", [ + # order p1 (yr 0), L=6 -> t*=6 inside p2 [5,8): lo=5, hi=8. + (6.0, "previous_seam", "p1", 5.0), + (6.0, "next_seam", "p1", 8.0), + (6.0, "closest_seam", "p1", 5.0), # |6-5|=1 < |6-8|=2 + # order p1, L=4 -> t*=4 inside p1 [0,5): lo=0, hi=5. + (4.0, "next_seam", "p1", 5.0), + (4.0, "closest_seam", "p1", 5.0), # |4-0|=4 > |4-5|=1 -> hi + # order p1, L=6.5 -> t*=6.5, exact midpoint 5<->8: tie -> hi. + (6.5, "closest_seam", "p1", 8.0), + (6.5, "previous_seam", "p1", 5.0), + (6.5, "next_seam", "p1", 8.0), + # order p2 (yr 5), L=10 -> t*=15 inside p3 [8,20): lo=8, hi=20. + (10.0, "previous_seam", "p2", 8.0), + (10.0, "next_seam", "p2", 20.0), + (10.0, "closest_seam", "p2", 20.0), # |15-8|=7 > |15-20|=5 -> hi + # order p3 (yr 8), L=15 -> t*=23 > max seam 20: hi absent. + (15.0, "previous_seam", "p3", 20.0), # snap to last seam + (15.0, "closest_seam", "p3", 20.0), # snap to last seam +]) +def test_seam_snap(lead, method, order, expected) -> None: + assert _yr_c(lead, method, order) == expected + + +def test_next_seam_out_of_horizon_is_inf() -> None: + """order p3 (yr 8), L=15 -> t*=23 > 20, next_seam -> +inf sentinel + (no in-horizon commissioning seam -> capacity never alive).""" + got = _yr_c(15.0, "next_seam", "p3") + assert got is not None and math.isinf(got) + + +def test_previous_seam_snapping_back_to_order_is_dropped() -> None: + """order p1 (yr 0), L=4, previous_seam -> lo=0 == yr(o) -> no effective + lag -> the row is dropped -> helper returns None (byte-parity).""" + assert _yr_c(4.0, "previous_seam", "p1") is None + + +# --- immediate / L=0 -> None (byte-parity path) ------------------------ + +def test_immediate_method_returns_none() -> None: + """Explicit immediate suppresses the lag even with L>0 -> None.""" + assert _yr_c(6.0, "immediate", "p1") is None + + +def test_zero_lead_returns_none() -> None: + assert _yr_c(0.0, "closest_seam", "p1") is None + + +def test_no_lead_param_returns_none() -> None: + """No construction_lead_time param at all -> L=0 everywhere -> None.""" + src = _StubSource({("solve", "years_from_start"): _years_df()}) + assert commissioning_year_lf(src, "s", _anchor("p1"), PERIODS) is None + + +# --- §7e two-level default --------------------------------------------- + +def test_default_lag_is_closest_seam() -> None: + """L>0, no explicit method -> closest_seam (order p1 L=6 -> 5).""" + assert _yr_c(6.0, None, "p1") == 5.0 + + +def test_default_no_lead_is_immediate() -> None: + """L=0, no explicit method -> immediate -> None.""" + assert _yr_c(0.0, None, "p1") is None + + +def test_explicit_method_wins_over_default() -> None: + """L>0 with explicit previous_seam -> honoured (order p1 L=6 -> 5).""" + assert _yr_c(6.0, "previous_seam", "p1") == 5.0 From 19a858b31502554b69e8cb1a4a788259bd922dce Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:18:09 +0300 Subject: [PATCH 50/87] feat(invest): thread commissioning lag into the availability walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice H P2 — the single change of substance. period_walk_iterator gains an optional commission_lf kwarg: when supplied, the four availability filters compare yr_dall against yr_avail = coalesce(yr_c, yr_d) instead of yr_d (both the lower bound and the lifetime-expiry upper bound yr_avail + life), so ordered capacity becomes available at its commissioning seam and gets its full life from commissioning. commission_lf is None -> yr_avail == yr_d -> every filter byte-identical to today. [F3a] mandatory e/d cast on the join keys (Enum/Utf8 skew would null yr_c and silently drop the lag). [F3 round-2 guard] raise ONLY when the pre-cast string-normalized [e,d] overlap is non-empty but post-cast survivors are zero — the real dtype/calendar desync — never on a legitimate per-cohort zero-overlap (globally-computed commission_lf vs a cohort with no lag-active entity). commission_lf threaded through _edd_history_lf_for + the three cohort wrappers + edd_history_lf + edd_invest_set_lf + edd_invest_lookback_set_lf (+ the eager edd_invest_lookback_set_from_source). Computed ONCE per solve at the derive sites (main + synthetic snapshot) over the ed_invest order-period anchors, provider left to the helper's F2 mirror to match the walk's calendar. NPV/cost walks never receive it -> cost stays at the order period (overnight, unchanged). Tests: deterministic lag alive-set per method, immediate/L=0 byte-parity, lifetime-from-commissioning, and the round-2 F3 guard (raises on dtype desync, does not raise on zero-overlap cohort). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_existing.py | 70 ++++++--- flextool/engine_polars/_derived_params.py | 51 +++++- flextool/engine_polars/_derived_walks.py | 71 ++++++++- .../test_construction_lead_time_edd.py | 147 ++++++++++++++++++ .../test_construction_lead_time_guard.py | 110 +++++++++++++ 5 files changed, 412 insertions(+), 37 deletions(-) create mode 100644 tests/engine_polars/test_construction_lead_time_edd.py create mode 100644 tests/engine_polars/test_construction_lead_time_guard.py diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index 38a0299fd..a96b0d3d6 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -424,10 +424,18 @@ def commissioning_year_lf(source: "InputSource", .join(pyd_d, on="d", how="left") .with_columns(pl.col("yr_o").fill_null(0.0))) - # Seam set S = distinct period-boundary years (branch-invariant). - seams = (pyd_lf.select(pl.col("yr").alias("seam")) - .unique() - .sort("seam")) + # Seam set S = distinct period-boundary years over ``period_in_use`` + # (design §2.1) — the values ``yr_dall`` actually takes in the walk. + # Restricting to period_in_use is load-bearing: a stray out-of-horizon + # period in ``p_years_d`` would otherwise become a phantom ``hi`` seam + # and corrupt closest/next snapping (design §2.2 relies on S being + # exactly the in-use year set). Branch-invariant as a set (branch + # copies duplicate anchor years). + seams = (pyd_lf + .filter(pl.col("d").cast(pl.Utf8).is_in(period_in_use)) + .select(pl.col("yr").alias("seam")) + .unique() + .sort("seam")) inf = float("inf") work = (work @@ -552,6 +560,7 @@ def edd_history_choice_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_choice``. @@ -560,13 +569,13 @@ def edd_history_choice_lf(source: "InputSource", keep iff pdy[d] >= pdy[d_h] AND pdy[d] < pdy[d_h] + life[e, d_h] - ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; - always ``None`` until the recourse flag lands (Slice C/D). + ``lineage`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, method="reinvest_choice", bounded=True, workdir=workdir, - lineage=lineage) + lineage=lineage, commission_lf=commission_lf) def edd_history_automatic_lf(source: "InputSource", @@ -576,6 +585,7 @@ def edd_history_automatic_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_automatic`` — entities with ``lifetime_method = reinvest_automatic``. @@ -584,13 +594,13 @@ def edd_history_automatic_lf(source: "InputSource", keep iff pdy[d] >= pdy[d_h] - ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; - always ``None`` until the recourse flag lands (Slice C/D). + ``lineage`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, method="reinvest_automatic", bounded=False, workdir=workdir, - lineage=lineage) + lineage=lineage, commission_lf=commission_lf) def edd_history_no_investment_lf(source: "InputSource", @@ -600,19 +610,20 @@ def edd_history_no_investment_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_no_investment``. Mirror of ``invest_divest_sets.py:247-248``: same predicate as ``edd_history_choice`` but for the ``no_investment`` cohort. - ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; - always ``None`` until the recourse flag lands (Slice C/D). + ``lineage`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. """ return _edd_history_lf_for( source, active_solve, period_with_history, period_in_use, method="no_investment", bounded=True, workdir=workdir, - lineage=lineage) + lineage=lineage, commission_lf=commission_lf) def _edd_history_lf_for(source: "InputSource", @@ -624,12 +635,14 @@ def _edd_history_lf_for(source: "InputSource", bounded: bool, workdir: Path | None = None, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Internal helper: build edd_history sub-set for a single lifetime_method cohort. - ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; - always ``None`` until the recourse flag lands (Slice C/D). + ``lineage`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. ``commission_lf`` shifts + this cohort's availability window to the commissioning year (Slice H). """ if not period_with_history or not period_in_use: return pl.LazyFrame(schema={ @@ -663,14 +676,14 @@ def _edd_history_lf_for(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK, life_lf=life_lf, factor_side=None, - workdir=workdir, lineage=lineage) + workdir=workdir, lineage=lineage, commission_lf=commission_lf) else: walk = period_walk_iterator( source, active_solve, anchor, period_in_use, period_in_use, window_method=WindowMethod.UNBOUNDED_FORWARD, life_lf=None, factor_side=None, - workdir=workdir, lineage=lineage) + workdir=workdir, lineage=lineage, commission_lf=commission_lf) return walk.pipe(rename_to_axis, {"d": "d_history", "d_all": "d"}) @@ -681,6 +694,7 @@ def edd_history_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for the union ``edd_history`` set. @@ -690,8 +704,8 @@ def edd_history_lf(source: "InputSource", :func:`edd_history_automatic_lf`, :func:`edd_history_no_investment_lf`). - ``lineage`` — see :func:`._derived_walks.period_walk_iterator`; - always ``None`` until the recourse flag lands (Slice C/D). + ``lineage`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. """ if not period_with_history or not period_in_use: return pl.LazyFrame(schema={ @@ -702,13 +716,16 @@ def edd_history_lf(source: "InputSource", parts = [ edd_history_choice_lf(source, active_solve, period_with_history, period_in_use, - workdir, lineage=lineage), + workdir, lineage=lineage, + commission_lf=commission_lf), edd_history_automatic_lf(source, active_solve, period_with_history, period_in_use, - workdir, lineage=lineage), + workdir, lineage=lineage, + commission_lf=commission_lf), edd_history_no_investment_lf(source, active_solve, period_with_history, period_in_use, - workdir, lineage=lineage), + workdir, lineage=lineage, + commission_lf=commission_lf), ] return pl.concat(parts, how="vertical").unique() @@ -861,6 +878,7 @@ def edd_invest_set_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_history_invest`` filtered to ``(e, d_invest) ∈ ed_invest``. @@ -880,7 +898,8 @@ def edd_invest_set_lf(source: "InputSource", """ edd = edd_history_lf(source, active_solve, period_with_history, period_in_use, - workdir, lineage=lineage) + workdir, lineage=lineage, + commission_lf=commission_lf) inv_pairs = ed_invest_lf.pipe(rename_to_axis, {"d": "d_history"}) return (edd .join(inv_pairs, on=["e", "d_history"], how="inner") @@ -895,6 +914,7 @@ def edd_invest_lookback_set_lf(source: "InputSource", workdir: Path | None = None, *, lineage: pl.DataFrame | None = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_invest_lookback_set``. @@ -981,7 +1001,7 @@ def edd_invest_lookback_set_lf(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.STRICT_LOOKBACK_BOUNDED, life_lf=life_lf, factor_side=None, - workdir=workdir, lineage=lineage) + workdir=workdir, lineage=lineage, commission_lf=commission_lf) # Unbounded cohort — strict-lookback only, no lifetime cap. unbounded_anchor = anchor_lf.join(unbounded_e, on="e", how="inner") @@ -995,7 +1015,7 @@ def edd_invest_lookback_set_lf(source: "InputSource", period_in_use, period_in_use, window_method=WindowMethod.STRICT_LOOKBACK_UNBOUNDED, life_lf=None, factor_side=None, - workdir=workdir, lineage=lineage) + workdir=workdir, lineage=lineage, commission_lf=commission_lf) return (pl.concat([bounded_walk, unbounded_walk], how="vertical") .pipe(rename_to_axis, {"d": "d_invest", "d_all": "d"}) diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 38b049a45..1530e89f4 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -4020,6 +4020,7 @@ def edd_invest_lookback_set_from_source(source: "InputSource", *, provider: "object | None" = None, lineage: pl.DataFrame | None = None, + commission_lf: "pl.LazyFrame | None" = None, ) -> pl.DataFrame | None: """Build the strict-lookback (e, d_invest, d) tuples used by the user-constraint LHS prebuilt-capacity term (mod L2885-2898). @@ -4072,7 +4073,7 @@ def edd_invest_lookback_set_from_source(source: "InputSource", ed_invest_lf = ed_invest.lazy().select("e", "d") return edd_invest_lookback_set_lf( source, active_solve, ed_invest_lf, periods, workdir, - lineage=lineage).collect() + lineage=lineage, commission_lf=commission_lf).collect() def edd_divest_active_from_source(source: "InputSource", @@ -5809,10 +5810,34 @@ def apply_derived_c( # edd_invest_lookback uses the (possibly-overlaid) ed_invest_set. ed_inv_used = ed_inv_db if (ed_inv_db is not None and ed_inv_db.height > 0) \ else getattr(flex_data, "ed_invest_set", None) + + # Slice H — construction lead time. Build the commissioning-year frame + # ONCE per solve over the ed_invest order-period anchors and thread it + # into BOTH availability walks below (edd_invest_lookback_set + + # edd_invest_set). ``None`` when no entity has a lag-active method -> + # byte-parity. provider is left to the helper's [F2] mirror (matching + # the walks, which resolve provider=None -> _recourse_walk_provider()); + # the NPV/cost walks never receive it, so cost stays at the order period. + _c_period_in_use = _period_in_use_set(source, active_solve, workdir, + ctx=ctx, provider=provider) + commission_lf = None + if ed_inv_used is not None and ed_inv_used.height > 0 and _c_period_in_use: + from flextool.engine_polars._derived_existing import ( + commissioning_year_lf as _commissioning_year_lf, + ) + try: + commission_lf = _commissioning_year_lf( + source, active_solve, + ed_inv_used.lazy().select("e", "d"), + list(_c_period_in_use), workdir=workdir) + except Exception: + commission_lf = None + try: eil_db = edd_invest_lookback_set_from_source( source, active_solve, ed_inv_used, workdir, - provider=provider, lineage=_c_lineage) + provider=provider, lineage=_c_lineage, + commission_lf=commission_lf) except LineageFilterError: raise except Exception: @@ -5827,15 +5852,14 @@ def apply_derived_c( from flextool.engine_polars._derived_existing import ( edd_invest_set_lf as _edd_invest_lf, ) - period_in_use = _period_in_use_set(source, active_solve, workdir, - ctx=ctx, provider=provider) + period_in_use = _c_period_in_use period_with_history = (_read_period_with_history(workdir, provider=provider) or list(period_in_use)) try: edd_inv_db = _edd_invest_lf( source, active_solve, ed_inv_used.lazy(), period_with_history, period_in_use, workdir, - lineage=_c_lineage).collect() + lineage=_c_lineage, commission_lf=commission_lf).collect() except LineageFilterError: raise except Exception: @@ -10266,6 +10290,21 @@ def apply_synthetic_invest_sets(flex_data: object, period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider) period_with_history = (_read_period_with_history(workdir, provider=provider) or list(period_in_use)) + # Slice H — commissioning-year frame for the synthetic snapshot + # (deterministic lag applies to every solve). provider is left to + # the helper's [F2] mirror (matching the walk, provider=None); the + # per-sub-solve anchor scope armed at the top of this fn feeds it. + commission_lf = None + if period_in_use: + from flextool.engine_polars._derived_existing import ( + commissioning_year_lf as _commissioning_year_lf, + ) + try: + commission_lf = _commissioning_year_lf( + source, active_solve, ed_inv.lazy().select("e", "d"), + list(period_in_use), workdir=workdir) + except Exception: # pragma: no cover — defensive + commission_lf = None # Slice D §8 — synthetic edd lineage filter (gated + hoisted check). # Build on-demand from the provider (branch-cluster runs later); the # anchor-pairs holder armed at the top of this fn is the conjunct. @@ -10283,7 +10322,7 @@ def apply_synthetic_invest_sets(flex_data: object, edd_inv = _edd_invest_lf( source, active_solve, ed_inv.lazy(), period_with_history, period_in_use, workdir, - lineage=_s_lineage).collect() + lineage=_s_lineage, commission_lf=commission_lf).collect() except LineageFilterError: raise except Exception: # pragma: no cover — defensive diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index c0762180d..480918924 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -197,6 +197,7 @@ def period_walk_iterator( workdir = None, lineage: pl.DataFrame | None = None, provider: "object | None" = None, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy per-(e, d) walk over ``period_in_use``, gated by lifetime. @@ -251,6 +252,18 @@ def period_walk_iterator( pointer comparison. The two empty-input early returns below deliberately do not consult ``lineage`` (with no pairs there is nothing to filter). + commission_lf + Optional ``[e, d, yr_c]`` commissioning-year frame (Slice H + construction lead time — see + :func:`._derived_existing.commissioning_year_lf`). When provided, + the availability filters compare ``yr_dall`` against + ``yr_avail = coalesce(yr_c, yr_d)`` instead of ``yr_d`` (both the + lower bound and the lifetime-expiry upper bound + ``yr_avail + life``), so ordered capacity becomes available at its + commissioning seam, not the order period. ``None`` (the default, + and every NPV/cost caller) leaves ``yr_avail ≡ yr_d`` — the filters + are byte-identical to today, so cost stays keyed on the order + period. Returns ------- @@ -342,10 +355,54 @@ def period_walk_iterator( ) ) + # Slice H — construction lead time. Swap the anchor year ``yr_d`` for + # a per-(e, d_invest) COMMISSIONING year ``yr_c`` in the availability + # filters (design §2.5). ``commission_lf`` is passed only by the + # availability callers; the NPV/cost callers never pass it, so cost + # stays keyed on the order period (overnight, unchanged). + if commission_lf is not None: + # [F3a] MANDATORY cast — the walk's e/d dtypes may be Enum while + # commission_lf emits Utf8. A dtype-skewed left-join yields + # all-null yr_c -> coalesce -> yr_d -> the lag SILENTLY vanishes. + # Align both keys exactly as the pyd dance above does for d. + walk_e_dtype = walk.collect_schema().get("e", pl.Utf8) + c_lf = commission_lf.select("e", "d", "yr_c").with_columns( + pl.col("e").cast(walk_e_dtype, strict=False), + pl.col("d").cast(ed_d_dtype, strict=False)) + # [F3 GUARD, round-2] Raise ONLY when the PRE-CAST (string- + # normalized) [e,d] key-overlap between the walk's anchors and + # commission_lf is NON-EMPTY but the POST-CAST join yields zero + # non-null survivors — that is the real dtype/calendar desync (or + # partial null-poisoning). A globally-computed commission_lf + # legitimately has ZERO overlap with a cohort carrying no + # lag-active entity (the availability walks are per-cohort), which + # must NOT raise. + anchors_str = walk.select( + pl.col("e").cast(pl.Utf8), pl.col("d").cast(pl.Utf8)).unique() + c_str = commission_lf.select( + pl.col("e").cast(pl.Utf8), pl.col("d").cast(pl.Utf8)).unique() + pre_overlap = (anchors_str + .join(c_str, on=["e", "d"], how="inner") + .select(pl.len()).collect().item()) + walk = (walk + .join(c_lf, on=["e", "d"], how="left") + .with_columns(yr_avail=pl.coalesce("yr_c", "yr_d"))) + if pre_overlap > 0: + post_survivors = (walk + .select(pl.col("yr_c").is_not_null().sum()) + .collect().item()) + if post_survivors == 0: + raise ValueError( + "commission_lf join produced no surviving lag rows " + "despite a non-empty pre-cast key overlap — dtype or " + "calendar desync (see Slice H design §2.5 F3)") + else: + walk = walk.with_columns(yr_avail=pl.col("yr_d")) + if window_method == WindowMethod.UNBOUNDED_FORWARD: - walk = walk.filter(pl.col("yr_dall") >= pl.col("yr_d")) + walk = walk.filter(pl.col("yr_dall") >= pl.col("yr_avail")) elif window_method == WindowMethod.STRICT_LOOKBACK_UNBOUNDED: - walk = walk.filter(pl.col("yr_dall") > pl.col("yr_d")) + walk = walk.filter(pl.col("yr_dall") > pl.col("yr_avail")) elif window_method == WindowMethod.STRICT_LOOKBACK_BOUNDED: # Align life_lf's dim-column dtypes to walk's before joining. walk_schema = walk.collect_schema() @@ -355,8 +412,9 @@ def period_walk_iterator( walk = (walk .join(life_lf, on=["e", "d"], how="left") .with_columns(life=pl.col("life").fill_null(0.0)) - .filter(pl.col("yr_dall") > pl.col("yr_d")) - .filter(pl.col("yr_dall") < pl.col("yr_d") + pl.col("life")) + .filter(pl.col("yr_dall") > pl.col("yr_avail")) + .filter(pl.col("yr_dall") + < pl.col("yr_avail") + pl.col("life")) ) else: # BOUNDED / BOUNDED_INCLUSIVE_LOOKBACK walk_schema = walk.collect_schema() @@ -366,8 +424,9 @@ def period_walk_iterator( walk = (walk .join(life_lf, on=["e", "d"], how="left") .with_columns(life=pl.col("life").fill_null(0.0)) - .filter(pl.col("yr_dall") >= pl.col("yr_d")) - .filter(pl.col("yr_dall") < pl.col("yr_d") + pl.col("life")) + .filter(pl.col("yr_dall") >= pl.col("yr_avail")) + .filter(pl.col("yr_dall") + < pl.col("yr_avail") + pl.col("life")) ) # Scenario-lineage filter (Slice B) — PRE-aggregation, so the diff --git a/tests/engine_polars/test_construction_lead_time_edd.py b/tests/engine_polars/test_construction_lead_time_edd.py new file mode 100644 index 000000000..5c1da626f --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_edd.py @@ -0,0 +1,147 @@ +"""Slice H — construction lead time: availability-walk integration. + +Exercises the P2 chain end to end (``commissioning_year_lf`` -> +``period_walk_iterator`` commission injection -> availability filters -> +``edd_invest_set``) against a hand-built source on the design §2.3 uneven +calendar (seams ``S = {0, 5, 8, 20}``), without invoking the LP. Pins: + +* §7a deterministic lag — the alive (order -> available) set shifts by the + snapped commissioning year, per method; +* §7c byte-parity — ``immediate`` / ``L=0`` == no lead time at all; +* §7f lifetime-from-commissioning — a bounded cohort's window is + ``[yr_c, yr_c + life)``, not ``[yr_c, yr_order + life)``. + +The §2.5 [F3] silent-no-lag guard is pinned in +``test_construction_lead_time_guard``. +""" +from __future__ import annotations + +import polars as pl +import pytest + +from flextool.engine_polars._derived_existing import ( + commissioning_year_lf, + edd_invest_set_lf, +) + +PERIODS = ["p1", "p2", "p3", "p4"] +YEARS = [0.0, 5.0, 8.0, 20.0] +YR = dict(zip(PERIODS, YEARS)) + + +class _StubSource: + """Minimal source: ``entities`` + ``parameter`` from dicts.""" + + def __init__(self, entities: dict[str, pl.DataFrame], + params: dict[tuple[str, str], pl.DataFrame]): + self._entities = entities + self._params = params + + def entities(self, entity_class: str) -> pl.DataFrame: + try: + return self._entities[entity_class] + except KeyError: + raise KeyError(entity_class) from None + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _years_df() -> pl.DataFrame: + return pl.DataFrame({ + "name": ["s"] * len(PERIODS), + "period": PERIODS, + "value": YEARS, + }) + + +def _source(*, entity="nuke", lifetime_method="reinvest_automatic", + life=None, lead=None, method=None) -> _StubSource: + entities = {"unit": pl.DataFrame({"name": [entity]})} + params: dict[tuple[str, str], pl.DataFrame] = { + ("solve", "years_from_start"): _years_df(), + ("unit", "lifetime_method"): pl.DataFrame( + {"name": [entity], "value": [lifetime_method]}), + } + if life is not None: + params[("unit", "lifetime")] = pl.DataFrame( + {"name": [entity], "value": [float(life)]}) + if lead is not None: + params[("unit", "construction_lead_time")] = pl.DataFrame( + {"name": [entity], "value": [float(lead)]}) + if method is not None: + params[("unit", "construction_lead_time_method")] = pl.DataFrame( + {"name": [entity], "value": [method]}) + return _StubSource(entities, params) + + +def _alive(source, order="p1", entity="nuke"): + """Alive periods of edd_invest_set for (entity, order), with the lag + threaded through commissioning_year_lf exactly as the derive site does.""" + ed_invest = pl.LazyFrame({"e": [entity], "d": [order]}) + commission_lf = commissioning_year_lf( + source, "s", ed_invest, PERIODS) + edd = edd_invest_set_lf( + source, "s", ed_invest, PERIODS, PERIODS, + commission_lf=commission_lf).collect() + return sorted(edd.filter(pl.col("d_invest") == order)["d"].to_list()) + + +# --- §7a deterministic lag (reinvest_automatic — lower bound only) ------ + +def test_no_lead_time_alive_from_order() -> None: + """No lead time -> capacity alive from the order period (p1..p4).""" + assert _alive(_source(lead=None, method=None)) == PERIODS + + +@pytest.mark.parametrize("method,expected", [ + # order p1 (yr 0), L=6 -> t*=6. previous/closest -> c=5 (p2,p3,p4); + # next -> c=8 (p3,p4). + ("previous_seam", ["p2", "p3", "p4"]), + ("closest_seam", ["p2", "p3", "p4"]), + ("next_seam", ["p3", "p4"]), +]) +def test_lag_shifts_alive_set_per_method(method, expected) -> None: + assert _alive(_source(lead=6.0, method=method)) == expected + + +def test_immediate_is_byte_parity_with_no_lead() -> None: + """§7c — explicit immediate (even with L>0) == no lead time at all.""" + lagged = _alive(_source(lead=6.0, method="immediate")) + base = _alive(_source(lead=None, method=None)) + assert lagged == base == PERIODS + + +def test_zero_lead_is_byte_parity() -> None: + """§7c — L=0 with a method still resolves to immediate -> no shift.""" + assert _alive(_source(lead=0.0, method="closest_seam")) == PERIODS + + +def test_default_method_is_closest_seam() -> None: + """L>0, no explicit method -> closest_seam -> c=5 (p2,p3,p4).""" + assert _alive(_source(lead=6.0, method=None)) == ["p2", "p3", "p4"] + + +# --- §7f lifetime-from-commissioning (bounded cohort) ------------------- + +def test_lifetime_from_commissioning_bounded_cohort() -> None: + """reinvest_choice, L=6 next_seam (c=8), life=15: alive [8, 23) -> + p3 (8), p4 (20) — NOT [8, 15) (which would truncate to p3 only) and + NOT [0, 15) (lifetime-from-order, no lag).""" + src = _source(lifetime_method="reinvest_choice", life=15.0, + lead=6.0, method="next_seam") + assert _alive(src) == ["p3", "p4"] + + +def test_bounded_cohort_no_lag_control() -> None: + """Same bounded cohort with no lead time: alive [0, 15) -> p1, p2, p3 + (yr 0/5/8 < 15; p4 yr 20 expired). Proves the lag is what moves the + window in the test above.""" + src = _source(lifetime_method="reinvest_choice", life=15.0) + assert _alive(src) == ["p1", "p2", "p3"] diff --git a/tests/engine_polars/test_construction_lead_time_guard.py b/tests/engine_polars/test_construction_lead_time_guard.py new file mode 100644 index 000000000..d132b90f6 --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_guard.py @@ -0,0 +1,110 @@ +"""Slice H — §2.5 [F3] silent-no-lag guard (round-2 corrected). + +The guard on ``period_walk_iterator``'s ``commission_lf`` join must: + +* RAISE when the PRE-CAST (string-normalized) ``[e, d]`` key-overlap + between the walk anchors and ``commission_lf`` is non-empty but the + POST-CAST join yields zero non-null ``yr_c`` survivors — the real + dtype/calendar desync that would otherwise silently drop the lag and + produce a byte-parity-looking (but wrong) unlagged model; +* NOT raise on a legitimate multi-cohort model where ``commission_lf`` was + computed globally but has ZERO overlap with a cohort carrying no + lag-active entity (the availability walks are per-cohort). + +The desync is simulated with a dtype-skewed ``commission_lf``: its ``d`` +column is ``Int64`` whose string form matches the walk's Enum labels (so +the pre-cast overlap is non-empty) but which casts to null against the +Enum target (so the post-cast join drops every lag row). +""" +from __future__ import annotations + +import polars as pl +import pytest + +from flextool.engine_polars._derived_walks import ( + WindowMethod, + period_walk_iterator, +) + +# Numeric period labels so an Int64 commission key string-matches the +# Enum labels while casting to null against the Enum. +PERIODS = ["1", "2", "3", "4"] +YEARS = [0.0, 5.0, 8.0, 20.0] +D_ENUM = pl.Enum(PERIODS) + + +class _StubSource: + def __init__(self, params): + self._params = params + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _source() -> _StubSource: + return _StubSource({ + ("solve", "years_from_start"): pl.DataFrame({ + "name": ["s"] * len(PERIODS), + "period": PERIODS, + "value": YEARS, + }), + }) + + +def _ed_lf(entity: str = "nuke") -> pl.LazyFrame: + return pl.DataFrame({"e": [entity]}).with_columns( + pl.Series("d", ["1"], dtype=D_ENUM)).lazy() + + +def _walk(commission_lf, entity: str = "nuke"): + return period_walk_iterator( + _source(), "s", _ed_lf(entity), PERIODS, PERIODS, + window_method=WindowMethod.UNBOUNDED_FORWARD, + life_lf=None, factor_side=None, commission_lf=commission_lf) + + +def test_guard_raises_on_dtype_desync() -> None: + """Int64 d string-matches the Enum anchor '1' (pre-cast overlap > 0) + but casts to null against the Enum (post-cast survivors == 0) -> the + guard makes the silent-no-lag failure loud.""" + bad = pl.DataFrame({ + "e": ["nuke"], + "d": pl.Series("d", [1], dtype=pl.Int64), # string '1' == anchor + "yr_c": [5.0], + }).lazy() + with pytest.raises(ValueError, match="no surviving lag rows"): + _walk(bad).collect() + + +def test_guard_does_not_raise_on_zero_overlap_cohort() -> None: + """A globally-computed commission_lf whose only entity ('gas') is NOT + in this cohort's walk ('nuke') has zero pre-cast overlap -> the guard + must NOT raise (round-2 MANDATORY case); the walk returns the unlagged + set for this cohort.""" + other = pl.DataFrame({ + "e": ["gas"], + "d": pl.Series("d", ["1"], dtype=D_ENUM), + "yr_c": [5.0], + }).lazy() + out = _walk(other).collect() # must not raise + # nuke ordered at '1' (yr 0), no lag for this cohort -> alive all. + assert sorted(out["d_all"].cast(pl.Utf8).to_list()) == PERIODS + + +def test_matching_commission_applies_lag_without_raising() -> None: + """A well-typed commission_lf that overlaps the cohort applies the lag + (order '1' -> commission year 5 -> alive '2','3','4') and does not + raise — the positive control for the guard.""" + good = pl.DataFrame({ + "e": ["nuke"], + "d": pl.Series("d", ["1"], dtype=D_ENUM), + "yr_c": [5.0], + }).lazy() + out = _walk(good).collect() + assert sorted(out["d_all"].cast(pl.Utf8).to_list()) == ["2", "3", "4"] From 1dbeca7b89cf5e79e5cdfb722d33d3191a697c07 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:20:40 +0300 Subject: [PATCH 51/87] feat(invest): forbid forced out-of-horizon commissioning (F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice H — the OQ-7 F5 guard. A next_seam order whose commissioning falls beyond the last seam gets yr_c=+inf (buys no in-horizon capacity). For a voluntary order that is inert; but when invest_forced / invest_min_period PINS v_invest at that order period, the solve would pay the annuity for capacity it never receives (or the forced target is unmeetable). assert_no_forced_out_of_horizon_commission raises a clear FlexToolConfigError for any +inf commission row that is also forced, called at the derive site OUTSIDE the commissioning-helper exception swallow so it actually surfaces. Voluntary and in-horizon (closest/ previous snap to the last seam) orders are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_existing.py | 53 +++++++++++++++++++ flextool/engine_polars/_derived_params.py | 4 ++ .../test_construction_lead_time_edd.py | 45 +++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index a96b0d3d6..70659f22a 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -481,6 +481,59 @@ def commissioning_year_lf(source: "InputSource", return out +def assert_no_forced_out_of_horizon_commission( + source: "InputSource", + commission_lf: pl.LazyFrame | None, + ) -> None: + """[F5] Forbid a FORCED order whose commissioning is out-of-horizon. + + An order whose ``next_seam`` commissioning falls beyond the last seam + gets ``yr_c = +inf`` (buys no in-horizon capacity — OQ-7). For a + *voluntary* order that is harmless (the model just never picks it). + But when ``invest_forced`` or ``invest_min_period`` PINS + ``v_invest >= target`` at that order period, the solve would pay the + annuity ``[yr_d, yr_d+life)`` for capacity it never receives (or the + forced target becomes unmeetable). Raise a clear + :class:`FlexToolConfigError` rather than solve a silently-degenerate + model (design §6 OQ-7 F5). + + ``commission_lf`` is the frame from :func:`commissioning_year_lf` + (``None`` -> nothing to check). Only its ``+inf`` rows are + out-of-horizon; ``closest_seam`` / ``previous_seam`` always snap to an + in-horizon seam. + """ + if commission_lf is None: + return + oo = (commission_lf + .filter(pl.col("yr_c").is_infinite()) + .select("e", "d")) + if oo.collect().height == 0: + return + # Does invest_forced or invest_min_period pin v_invest > 0 at (e, o)? + forced_parts: list[pl.LazyFrame] = [] + for name in ("invest_forced", "invest_min_period"): + resolved = _resolve_per_period_lf( + _per_entity_param_lf(source, name), oo, fill=0.0) + forced_parts.append( + resolved.filter(pl.col("value") > 0.0).select("e", "d")) + forced = pl.concat(forced_parts, how="vertical").unique() + bad = oo.join(forced, on=["e", "d"], how="inner").collect() + if bad.height > 0: + pairs = ", ".join(f"({e}, {d})" + for e, d in bad.select("e", "d").rows()) + from flextool.engine_polars._solve_state import FlexToolConfigError + raise FlexToolConfigError( + "construction_lead_time_method=next_seam pins a forced " + "investment (invest_forced / invest_min_period) whose " + "commissioning falls beyond the model horizon for: " + f"{pairs}. The forced capacity would never be delivered " + "in-horizon while still charged its annuity from the order " + "period. Either shorten the lead time, move the order earlier, " + "extend the horizon, drop the forced/min-period requirement, or " + "use a non-next_seam method (design §6 OQ-7 F5)." + ) + + # --------------------------------------------------------------------------- # §3.7.0 — entityInvest / entityDivest / e_invest_total / e_divest_total # --------------------------------------------------------------------------- diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 1530e89f4..23fadbfcd 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -5823,6 +5823,7 @@ def apply_derived_c( commission_lf = None if ed_inv_used is not None and ed_inv_used.height > 0 and _c_period_in_use: from flextool.engine_polars._derived_existing import ( + assert_no_forced_out_of_horizon_commission as _assert_no_forced_ooh, commissioning_year_lf as _commissioning_year_lf, ) try: @@ -5832,6 +5833,9 @@ def apply_derived_c( list(_c_period_in_use), workdir=workdir) except Exception: commission_lf = None + # [F5] Surface a forced out-of-horizon order as a config error — + # OUTSIDE the swallow above so it is not silenced. + _assert_no_forced_ooh(source, commission_lf) try: eil_db = edd_invest_lookback_set_from_source( diff --git a/tests/engine_polars/test_construction_lead_time_edd.py b/tests/engine_polars/test_construction_lead_time_edd.py index 5c1da626f..721e7b54e 100644 --- a/tests/engine_polars/test_construction_lead_time_edd.py +++ b/tests/engine_polars/test_construction_lead_time_edd.py @@ -20,9 +20,11 @@ import pytest from flextool.engine_polars._derived_existing import ( + assert_no_forced_out_of_horizon_commission, commissioning_year_lf, edd_invest_set_lf, ) +from flextool.engine_polars._solve_state import FlexToolConfigError PERIODS = ["p1", "p2", "p3", "p4"] YEARS = [0.0, 5.0, 8.0, 20.0] @@ -62,7 +64,8 @@ def _years_df() -> pl.DataFrame: def _source(*, entity="nuke", lifetime_method="reinvest_automatic", - life=None, lead=None, method=None) -> _StubSource: + life=None, lead=None, method=None, + invest_forced=None) -> _StubSource: entities = {"unit": pl.DataFrame({"name": [entity]})} params: dict[tuple[str, str], pl.DataFrame] = { ("solve", "years_from_start"): _years_df(), @@ -78,6 +81,10 @@ def _source(*, entity="nuke", lifetime_method="reinvest_automatic", if method is not None: params[("unit", "construction_lead_time_method")] = pl.DataFrame( {"name": [entity], "value": [method]}) + if invest_forced is not None: + period, val = invest_forced + params[("unit", "invest_forced")] = pl.DataFrame( + {"name": [entity], "period": [period], "value": [float(val)]}) return _StubSource(entities, params) @@ -145,3 +152,39 @@ def test_bounded_cohort_no_lag_control() -> None: window in the test above.""" src = _source(lifetime_method="reinvest_choice", life=15.0) assert _alive(src) == ["p1", "p2", "p3"] + + +# --- §7k [F5] forced-order out-of-horizon guard ------------------------- + +def _commission(src, order="p3"): + return commissioning_year_lf( + src, "s", pl.LazyFrame({"e": ["nuke"], "d": [order]}), PERIODS) + + +def test_forced_out_of_horizon_raises() -> None: + """order p3 (yr 8), L=15 next_seam -> t*=23 > 20 -> yr_c=+inf; with + invest_forced pinned at (nuke, p3) the guard raises FlexToolConfigError + rather than pay-for-never-delivered capacity.""" + src = _source(lead=15.0, method="next_seam", invest_forced=("p3", 1.0)) + commission_lf = _commission(src, "p3") + with pytest.raises(FlexToolConfigError, match="beyond the model horizon"): + assert_no_forced_out_of_horizon_commission(src, commission_lf) + + +def test_voluntary_out_of_horizon_does_not_raise() -> None: + """Same out-of-horizon order but NOT forced -> allowed (the sentinel + makes it inert; the model simply never picks it).""" + src = _source(lead=15.0, method="next_seam") + commission_lf = _commission(src, "p3") + # No raise. + assert_no_forced_out_of_horizon_commission(src, commission_lf) + + +def test_forced_in_horizon_does_not_raise() -> None: + """A forced order whose commissioning IS in-horizon (closest_seam snaps + to the last seam) is fine — only +inf (next_seam past the horizon) + trips the guard.""" + src = _source(lead=15.0, method="closest_seam", + invest_forced=("p3", 1.0)) + commission_lf = _commission(src, "p3") + assert_no_forced_out_of_horizon_commission(src, commission_lf) From a69abb9ab8f4fd6035824057314410a2a0af724c Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:23:53 +0300 Subject: [PATCH 52/87] test(invest): staging composition + calendar identity (Slice H P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice H P3 — no new code, a composition test over already-built machinery. §7b: on the §4.2 calendar with reveal at p3 and window {p1,p2}, the p3-serving order periods are {p3,...} for short-lead solar (reacts), {p1,p2} for gas (tied), {p1} for nuclear (fully tied) — all long-lead servers lie in the invest-NA window, so the existing non_anticipativity_invest families tie them across branches with no Slice H constraint. §7i: a branch-copied period (same anchor year) gets the identical commissioning year and alive set as its anchor (F2 calendar identity). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- .../test_construction_lead_time_recourse.py | 98 ++++++++++++++ .../test_construction_lead_time_staging.py | 125 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 tests/engine_polars/test_construction_lead_time_recourse.py create mode 100644 tests/engine_polars/test_construction_lead_time_staging.py diff --git a/tests/engine_polars/test_construction_lead_time_recourse.py b/tests/engine_polars/test_construction_lead_time_recourse.py new file mode 100644 index 000000000..a9a78dfff --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_recourse.py @@ -0,0 +1,98 @@ +"""Slice H — §7i seam-calendar identity under branch-copied periods. + +The [F2] risk is that a branch order period's ``yr(o)`` (from the +commissioning helper) desyncs from the ``yr_dall`` the availability walk +compares it against. Both read years through the identical +``_p_years_d_lf`` provider/workdir block, so a fan member period (a +byte-copy of its anchor, carrying the anchor's year) must receive the +EXACT same lag as its anchor. + +This is exercised with a branch-copied calendar: ``p2035`` and its fan +member ``p2035_low`` share year 15; ``p2040`` / ``p2040_low`` share year +20. An order at ``p2035_low`` must produce the same commissioning year +and the same alive set as an order at ``p2035`` — proving both landed on +one calendar (a pure variable-value check would not). +""" +from __future__ import annotations + +import polars as pl + +from flextool.engine_polars._derived_existing import ( + commissioning_year_lf, + edd_invest_set_lf, +) + +# h2020 is HISTORY (not in period_in_use); the two p2035 siblings share +# year 15, the two p2040 siblings share year 20 (branch byte-copies). +VOCAB = ["h2020", "p2035", "p2035_low", "p2040", "p2040_low"] +YEARS = [0.0, 15.0, 15.0, 20.0, 20.0] +PIU = ["p2035", "p2035_low", "p2040", "p2040_low"] + + +class _StubSource: + def __init__(self, entities, params): + self._entities = entities + self._params = params + + def entities(self, entity_class: str) -> pl.DataFrame: + try: + return self._entities[entity_class] + except KeyError: + raise KeyError(entity_class) from None + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _source(method="next_seam", lead=3.0, entity="nuke"): + return _StubSource( + {"unit": pl.DataFrame({"name": [entity]})}, + { + ("solve", "years_from_start"): pl.DataFrame({ + "name": ["s"] * len(VOCAB), + "period": VOCAB, "value": YEARS, + }), + ("unit", "lifetime_method"): pl.DataFrame( + {"name": [entity], "value": ["reinvest_automatic"]}), + ("unit", "construction_lead_time"): pl.DataFrame( + {"name": [entity], "value": [float(lead)]}), + ("unit", "construction_lead_time_method"): pl.DataFrame( + {"name": [entity], "value": [method]}), + }) + + +def _yr_c(src, order): + out = commissioning_year_lf( + src, "s", pl.LazyFrame({"e": ["nuke"], "d": [order]}), PIU) + df = out.collect() + return df.filter(pl.col("d") == order)["yr_c"][0] + + +def _alive(src, order): + ed = pl.LazyFrame({"e": ["nuke"], "d": [order]}) + commission_lf = commissioning_year_lf(src, "s", ed, PIU) + edd = edd_invest_set_lf( + src, "s", ed, PIU, PIU, commission_lf=commission_lf).collect() + return sorted(edd.filter(pl.col("d_invest") == order)["d"].to_list()) + + +def test_branch_copy_order_gets_same_commissioning_year() -> None: + """yr(p2035_low) == yr(p2035) == 15 -> identical snapped yr_c.""" + src = _source() + assert _yr_c(src, "p2035_low") == _yr_c(src, "p2035") + + +def test_branch_copy_order_gets_same_alive_set() -> None: + """The alive (order -> available) set for the branch order equals its + byte-copied anchor's — the calendar-identity guarantee (§7i).""" + src = _source() + anchor_alive = _alive(src, "p2035") + branch_alive = _alive(src, "p2035_low") + # order yr 15, L=3 -> t*=18, next_seam -> yr_c=20 -> alive p2040 siblings. + assert branch_alive == anchor_alive == ["p2040", "p2040_low"] diff --git a/tests/engine_polars/test_construction_lead_time_staging.py b/tests/engine_polars/test_construction_lead_time_staging.py new file mode 100644 index 000000000..ace05ac45 --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_staging.py @@ -0,0 +1,125 @@ +"""Slice H — §7b stochastic staging composition (solver-free). + +Encoding A keeps ``v_invest`` order-indexed; the invest-NA window +(``non_anticipativity_invest_periods``, v72) ties ``v_invest[e, o]`` across +branches for order periods ``o`` in the window. Staging is then a +CONSEQUENCE of the deterministic lag, needing no new constraint (design +§4): to be online at the reveal period a long-lead asset must be *ordered* +early enough that its commissioning reaches the reveal — and if that order +period falls in the (pre-reveal) window it is already tied. + +This pins the provable backbone of §4.2 without the LP: **which order +periods can make capacity available at the reveal period** under each lead +time. On the §4.2 calendar (p1=0, p2=5, p3=8, p4=20), reveal at p3, window += {p1, p2}: + +* solar (L=1) — a post-reveal order (p3) already serves p3 -> REACTS + (diverges per branch); +* gas (L=6) — no post-reveal order serves p3; the serving orders are + {p1, p2} ⊆ window -> the p3-serving decision is TIED; +* nuclear (L=12) — only order p1 serves p3 -> FULLY TIED. + +If those serving order periods lie in the window, the *existing* invest-NA +families tie them across branches — no Slice H constraint. +""" +from __future__ import annotations + +import polars as pl + +from flextool.engine_polars._derived_existing import ( + commissioning_year_lf, + edd_invest_set_lf, +) + +PERIODS = ["p1", "p2", "p3", "p4"] +YEARS = [0.0, 5.0, 8.0, 20.0] +REVEAL = "p3" +WINDOW = {"p1", "p2"} # pre-reveal (non_anticipativity_invest_periods) + + +class _StubSource: + def __init__(self, entities, params): + self._entities = entities + self._params = params + + def entities(self, entity_class: str) -> pl.DataFrame: + try: + return self._entities[entity_class] + except KeyError: + raise KeyError(entity_class) from None + + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: + try: + return self._params[(entity_class, name)] + except KeyError: + raise KeyError((entity_class, name)) from None + + def parameter_default(self, entity_class: str, name: str): + raise KeyError((entity_class, name)) + + +def _source(entity, lead, method="closest_seam"): + return _StubSource( + {"unit": pl.DataFrame({"name": [entity]})}, + { + ("solve", "years_from_start"): pl.DataFrame({ + "name": ["s"] * len(PERIODS), + "period": PERIODS, "value": YEARS, + }), + ("unit", "lifetime_method"): pl.DataFrame( + {"name": [entity], "value": ["reinvest_automatic"]}), + ("unit", "construction_lead_time"): pl.DataFrame( + {"name": [entity], "value": [float(lead)]}), + ("unit", "construction_lead_time_method"): pl.DataFrame( + {"name": [entity], "value": [method]}), + }) + + +def _alive(src, entity, order): + ed = pl.LazyFrame({"e": [entity], "d": [order]}) + commission_lf = commissioning_year_lf(src, "s", ed, PERIODS) + edd = edd_invest_set_lf( + src, "s", ed, PERIODS, PERIODS, commission_lf=commission_lf).collect() + return set(edd.filter(pl.col("d_invest") == order)["d"].to_list()) + + +def _orders_serving(entity, lead): + """Order periods whose commissioning makes capacity available at the + reveal period.""" + src = _source(entity, lead) + return {o for o in PERIODS if REVEAL in _alive(src, entity, o)} + + +def test_solar_short_lead_reacts_at_reveal() -> None: + """L=1: a post-reveal order (p3 itself) serves p3 -> solar can REACT to + the reveal (diverge per branch); staging does NOT force it early.""" + serving = _orders_serving("solar", 1.0) + assert REVEAL in serving, serving + assert not serving <= WINDOW, "short-lead must have a post-reveal server" + + +def test_gas_medium_lead_partly_tied() -> None: + """L=6: no post-reveal order reaches p3; the p3-serving orders are the + pre-reveal window {p1, p2} -> the p3-serving decision is TIED by the + existing invest-NA window.""" + serving = _orders_serving("gas", 6.0) + assert REVEAL not in serving, "a post-reveal order must NOT reach p3" + assert serving == {"p1", "p2"}, serving + assert serving <= WINDOW + + +def test_nuclear_long_lead_fully_tied() -> None: + """L=12: only order p1 reaches p3 -> fully tied to the earliest + pre-reveal order.""" + serving = _orders_serving("nuclear", 12.0) + assert serving == {"p1"}, serving + assert serving <= WINDOW + + +def test_staging_orders_lie_in_window_for_long_lead() -> None: + """The composition claim: for both long-lead techs every p3-serving + order lies in the invest-NA window, so the ALREADY-BUILT + non_anticipativity_invest families tie them across branches — no new + Slice H constraint (design §4).""" + for entity, lead in (("gas", 6.0), ("nuclear", 12.0)): + assert _orders_serving(entity, lead) <= WINDOW From 95c900f2f696732bf4ebfc6a15fe0b8900580288 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 29 Sep 2026 23:25:16 +0300 Subject: [PATCH 53/87] =?UTF-8?q?docs(invest):=20construction=20lead=20tim?= =?UTF-8?q?e=20=E2=80=94=20how-to=20+=20CHANGELOG=20(Slice=20H=20P4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the two per-entity knobs (construction_lead_time + _method), the four seam-snap methods, the order-vs-commissioning / cost-vs-availability split, the out-of-horizon (voluntary warn / forced error) behaviour, and the orthogonal composition with the invest non-anticipativity window for automatic stochastic staging. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- CHANGELOG.md | 21 +++++++++++++++++++-- docs/how_to.md | 8 ++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3976cf93d..1bce52e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,24 @@ ## Unreleased -Schema change (adds `solve.stochastic_invest_method` and -`solve.non_anticipativity_invest_periods`; migrated automatically). +Schema change (adds `solve.stochastic_invest_method`, +`solve.non_anticipativity_invest_periods`, and per-entity +`construction_lead_time` / `construction_lead_time_method` on +`unit`/`connection`/`node`; migrated automatically). + +- **Construction lead time.** New per-entity parameters + `construction_lead_time` ([years]) and `construction_lead_time_method` + (`immediate` / `closest_seam` / `previous_seam` / `next_seam`) model + physical build time on `unit`, `connection` and `node` investments. + Capacity *ordered* in a period becomes *available* only after the lead + time, snapped to a period boundary — the investment decision and its + overnight annuity stay in the order period, while balances, flows and + reserves see the plant (and its full technical lifetime) only from its + commissioning year. It applies to deterministic and stochastic models + alike, and composes with the invest non-anticipativity window to stage + long- versus short-lead technologies across a stochastic reveal + automatically (a long-lead asset serving a post-reveal period must be + ordered in a pre-reveal, tied period). Left at the default 0 (or + `immediate`) every existing result is byte-identical. - **Standard two-stage stochastic investment (the main mode).** The new `solve.non_anticipativity_invest_periods` parameter names the periods over diff --git a/docs/how_to.md b/docs/how_to.md index 94e77c6fa..115603d15 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -277,6 +277,14 @@ First, the investment parameters need to be included both for the *battery_inver Additional parameters: - `lifetime_method`: Model can either be forced to reinvest when the lifetime ends (`reinvest_automatic`), have a choice (`reinvest_choice`), or be restricted to a one-shot build that retires after its lifetime without rebuild (`no_investment` — for physical one-off additions like plant refurbishments). +- `construction_lead_time` [years]: physical build time. Capacity *ordered* in a period only becomes *available* this many years later — so `v_invest` still lives (and is costed) in the decision period, but the plant contributes to balances, flows and reserves only from its commissioning year. The lag is snapped to a period boundary by `construction_lead_time_method` (below). The plant's technical `lifetime` also starts at commissioning, so it operates its full life `L` years after the cost window (the construction years are financed but not yet producing — standard overnight-cost + interest-during-construction-lite semantics). Constant or period (a Map may set a different lead time per decision period). Default 0 (instantaneous, exactly as before). Applies to `unit`, `connection` and `node` investments, deterministic and stochastic alike. +- `construction_lead_time_method`: how the commissioning year `year(order) + construction_lead_time` snaps to a period boundary (capacity turns on at a period start): + - `immediate` — no lag; capacity is available from the decision period (byte-identical to prior behaviour). This is also the effective default when no lead time is set. + - `closest_seam` — nearest period boundary; an exact midpoint rounds *up* (later, conservative). This is the effective default once a lead time **is** set but no method is chosen. + - `previous_seam` — round the commissioning year *down* to the preceding boundary. + - `next_seam` — round *up* to the following boundary. If no in-horizon boundary is on/after the commissioning year the order buys no capacity within the model horizon (a warning is issued); a *forced* such order (`invest_forced` / `invest_min_period`) is rejected with a clear error rather than charged for never-delivered capacity. + + **Construction lead time and stochastics compose automatically.** Under recourse (`stochastic_invest_method = recourse`) with an invest non-anticipativity window (`non_anticipativity_invest_periods`, see the stochastics how-to), a long-lead technology that must be online *after* the branch reveal has to be *ordered* before it — in a pre-reveal, tied period — so its investment is a single shared here-and-now decision, while a short-lead technology can be ordered at/after the reveal and react per scenario. This staging is emergent (no extra parameter): set the window to the pre-reveal periods and the lead time stages the technologies for you. Note the two knobs stay orthogonal — the window is the *information* boundary (which order periods are shared), the lead time is the *physics* (how early you must order); you need both, set consistently, for staging. - `salvage_value`: Sets the extra value that can be gained for retiring [CUR/kW] - `fixed_cost`: Annual cost for capacity [CUR/kW] From ea3795a7225705a8585f25c07bb4c06f28c33b76 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 30 Sep 2026 00:15:59 +0300 Subject: [PATCH 54/87] fix(invest): surface commissioning-lag guard/helper failures (don't swallow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slice H silent-failure guards were defeated by broad `except Exception` swallows in the real solve path: * MAJOR-1 — the §2.5 [F3] join-desync guard raised a bare `ValueError` that the edd builders' `except Exception` in apply_derived_c / apply_synthetic_invest_sets caught, silently reverting to the unlagged path. Give it a dedicated `CommissioningLagError` (ValueError subclass, mirroring LineageFilterError) and re-raise it ahead of the broad except at all edd sites. * MINOR-3 — a commissioning_year_lf bug on a genuinely lag-configured model was swallowed to None → silently-unlagged wrong model. Route both build sites through a shared `_build_commission_lf` that re-raises when `construction_lead_time > 0` is configured, while still swallowing the no-lag case (helper returns None anyway → byte-parity). * MINOR-2 — mirror the F5 forced-out-of-horizon assert into the synthetic invest path, which also threads commission_lf into the walk. * NIT-4 — collect commissioning_year_lf's result once instead of twice. No-lag / immediate models never reach any new raise, so existing models stay byte-identical (recourse + invest-NA gates green, zero golden changes). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_existing.py | 28 +++- flextool/engine_polars/_derived_params.py | 88 +++++++++--- flextool/engine_polars/_derived_walks.py | 5 +- flextool/engine_polars/_solve_state.py | 25 ++++ .../test_construction_lead_time_guard.py | 135 +++++++++++++++++- 5 files changed, 248 insertions(+), 33 deletions(-) diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index 70659f22a..4a6bf5df2 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -476,9 +476,33 @@ def commissioning_year_lf(source: "InputSource", & (pl.col("yr_c") != pl.col("yr_o"))) .select("e", "d", "yr_c")) - if out.collect().height == 0: + out_df = out.collect() + if out_df.height == 0: return None - return out + return out_df.lazy() + + +def _construction_lag_configured(source: "InputSource") -> bool: + """True when some entity carries a non-default (> 0) + ``construction_lead_time`` — i.e. the model genuinely configures a + commissioning lag (Slice H MINOR-3). + + Used to decide whether a :func:`commissioning_year_lf` failure must be + LOUD (a lag-configured model → surface the bug rather than silently + solve the wrong, unlagged model) or is harmless (no lag anywhere → the + helper legitimately returns ``None`` and the walk keeps ``yr_d``, so a + swallow-to-``None`` is byte-parity-safe). Deliberately conservative: + any ``construction_lead_time > 0`` counts, even paired with an explicit + ``immediate`` method, so a real misconfiguration is never masked. + """ + try: + lead = _per_entity_param_lf(source, "construction_lead_time") + return bool( + lead.select((pl.col("value") > 0.0).any().alias("has")) + .collect() + .item()) + except Exception: # pragma: no cover — defensive: absence == no lag + return False def assert_no_forced_out_of_horizon_commission( diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 23fadbfcd..8a7c4a983 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -55,7 +55,7 @@ schema_dtype, ) from ._emit_provider_io import _provider_key -from ._solve_state import LineageFilterError +from ._solve_state import CommissioningLagError, LineageFilterError from ._param_shapes import ( broadcast_to_period_time, promote_param_to_dt, @@ -5575,6 +5575,50 @@ def _set_eq_sorted(a: pl.DataFrame | None, return a.sort(sk).equals(b.sort(sk)) +def _build_commission_lf(source: "InputSource", + active_solve: str | None, + ed_lf: "pl.LazyFrame", + period_in_use, + workdir: "Path | None", + ) -> "pl.LazyFrame | None": + """Slice H — build the commissioning-year frame, surfacing failures. + + Shared by :func:`apply_derived_c` and + :func:`apply_synthetic_invest_sets` (both thread the result into the + availability walk). Returns the ``[e, d, yr_c]`` lag frame, or ``None`` + when there are no in-use periods / no entity has a lag-active method. + + A :class:`CommissioningLagError` from the helper propagates unchanged. + Any OTHER helper failure is swallowed to ``None`` ONLY when the model + configures no construction lead time (``construction_lead_time`` all + ``<= 0`` -> the helper legitimately returns ``None`` -> byte-parity for + existing models). On a genuinely lag-configured model the failure is + re-raised as :class:`CommissioningLagError` so a real misconfiguration + is never masked into a silently-unlagged (byte-parity-looking but + wrong) solve (Slice H MINOR-3). + """ + if not period_in_use: + return None + from flextool.engine_polars._derived_existing import ( + _construction_lag_configured as _lag_configured, + commissioning_year_lf as _commissioning_year_lf, + ) + try: + return _commissioning_year_lf( + source, active_solve, ed_lf, list(period_in_use), + workdir=workdir) + except CommissioningLagError: + raise + except Exception as exc: + if _lag_configured(source): + raise CommissioningLagError( + "commissioning_year_lf failed on a model with a configured " + "construction_lead_time (> 0); refusing to silently solve " + "the unlagged model (Slice H MINOR-3)." + ) from exc + return None + + def apply_derived_c( flex_data: object, source: "InputSource", @@ -5824,17 +5868,13 @@ def apply_derived_c( if ed_inv_used is not None and ed_inv_used.height > 0 and _c_period_in_use: from flextool.engine_polars._derived_existing import ( assert_no_forced_out_of_horizon_commission as _assert_no_forced_ooh, - commissioning_year_lf as _commissioning_year_lf, ) - try: - commission_lf = _commissioning_year_lf( - source, active_solve, - ed_inv_used.lazy().select("e", "d"), - list(_c_period_in_use), workdir=workdir) - except Exception: - commission_lf = None - # [F5] Surface a forced out-of-horizon order as a config error — - # OUTSIDE the swallow above so it is not silenced. + # [MINOR-3] Failures on a lag-configured model surface as a + # CommissioningLagError (never a silent unlagged solve). + commission_lf = _build_commission_lf( + source, active_solve, ed_inv_used.lazy().select("e", "d"), + _c_period_in_use, workdir) + # [F5] Surface a forced out-of-horizon order as a config error. _assert_no_forced_ooh(source, commission_lf) try: @@ -5842,7 +5882,7 @@ def apply_derived_c( source, active_solve, ed_inv_used, workdir, provider=provider, lineage=_c_lineage, commission_lf=commission_lf) - except LineageFilterError: + except (LineageFilterError, CommissioningLagError): raise except Exception: eil_db = None @@ -5864,7 +5904,7 @@ def apply_derived_c( source, active_solve, ed_inv_used.lazy(), period_with_history, period_in_use, workdir, lineage=_c_lineage, commission_lf=commission_lf).collect() - except LineageFilterError: + except (LineageFilterError, CommissioningLagError): raise except Exception: edd_inv_db = None @@ -10298,17 +10338,19 @@ def apply_synthetic_invest_sets(flex_data: object, # (deterministic lag applies to every solve). provider is left to # the helper's [F2] mirror (matching the walk, provider=None); the # per-sub-solve anchor scope armed at the top of this fn feeds it. - commission_lf = None - if period_in_use: + # [MINOR-3] Failures on a lag-configured model surface loud. + commission_lf = _build_commission_lf( + source, active_solve, ed_inv.lazy().select("e", "d"), + period_in_use, workdir) + # [MINOR-2/F5] Mirror apply_derived_c: the synthetic invest path + # also threads commission_lf into the availability walk, so a forced + # next_seam order with +inf commissioning must be caught here too + # (this path may run without apply_derived_c preceding). + if commission_lf is not None: from flextool.engine_polars._derived_existing import ( - commissioning_year_lf as _commissioning_year_lf, + assert_no_forced_out_of_horizon_commission as _assert_no_forced_ooh, ) - try: - commission_lf = _commissioning_year_lf( - source, active_solve, ed_inv.lazy().select("e", "d"), - list(period_in_use), workdir=workdir) - except Exception: # pragma: no cover — defensive - commission_lf = None + _assert_no_forced_ooh(source, commission_lf) # Slice D §8 — synthetic edd lineage filter (gated + hoisted check). # Build on-demand from the provider (branch-cluster runs later); the # anchor-pairs holder armed at the top of this fn is the conjunct. @@ -10327,7 +10369,7 @@ def apply_synthetic_invest_sets(flex_data: object, source, active_solve, ed_inv.lazy(), period_with_history, period_in_use, workdir, lineage=_s_lineage, commission_lf=commission_lf).collect() - except LineageFilterError: + except (LineageFilterError, CommissioningLagError): raise except Exception: # pragma: no cover — defensive edd_inv = None diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index 480918924..a07318722 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -392,7 +392,10 @@ def period_walk_iterator( .select(pl.col("yr_c").is_not_null().sum()) .collect().item()) if post_survivors == 0: - raise ValueError( + from flextool.engine_polars._solve_state import ( + CommissioningLagError, + ) + raise CommissioningLagError( "commission_lf join produced no surviving lag rows " "despite a non-empty pre-cast key overlap — dtype or " "calendar desync (see Slice H design §2.5 F3)") diff --git a/flextool/engine_polars/_solve_state.py b/flextool/engine_polars/_solve_state.py index 8a43a76d6..65d41017f 100644 --- a/flextool/engine_polars/_solve_state.py +++ b/flextool/engine_polars/_solve_state.py @@ -55,6 +55,31 @@ class LineageFilterError(ValueError): """ +class CommissioningLagError(ValueError): + """Raised when the Slice H construction-lead-time pipeline cannot apply + the commissioning lag soundly on a genuinely lag-configured model. + + Two failure modes carry this type: + + * the ``commission_lf`` [e, d] join in + :func:`._derived_walks.period_walk_iterator` desyncs (a non-empty + pre-cast key overlap collapses to zero post-cast survivors — a dtype + or calendar skew that would otherwise silently drop the lag); and + * :func:`._derived_existing.commissioning_year_lf` throws on a model + whose ``construction_lead_time`` is set (> 0). + + Subclasses ``ValueError`` (like :class:`LineageFilterError`) so it can + be raised from the guards without churning any ``pytest.raises( + ValueError)`` pins. Its dedicated type lets the blanket + ``except Exception`` swallows in ``apply_derived_c`` / + ``apply_synthetic_invest_sets`` re-raise lag failures loudly instead of + silently reverting to an unlagged (byte-parity-looking but wrong) + model — the exact silent failure the Slice H guards exist to prevent. + A no-lag model never reaches these raises (the helper returns ``None`` + and the walk keeps ``yr_d``), so existing models stay byte-identical. + """ + + # --------------------------------------------------------------------------- # Lightweight value types # --------------------------------------------------------------------------- diff --git a/tests/engine_polars/test_construction_lead_time_guard.py b/tests/engine_polars/test_construction_lead_time_guard.py index d132b90f6..dea84c2f2 100644 --- a/tests/engine_polars/test_construction_lead_time_guard.py +++ b/tests/engine_polars/test_construction_lead_time_guard.py @@ -21,10 +21,16 @@ import polars as pl import pytest +from flextool.engine_polars._derived_existing import ( + _construction_lag_configured, + edd_invest_set_lf, +) +from flextool.engine_polars._derived_params import _build_commission_lf from flextool.engine_polars._derived_walks import ( WindowMethod, period_walk_iterator, ) +from flextool.engine_polars._solve_state import CommissioningLagError # Numeric period labels so an Int64 commission key string-matches the # Enum labels while casting to null against the Enum. @@ -37,6 +43,11 @@ class _StubSource: def __init__(self, params): self._params = params + def entities(self, entity_class: str) -> pl.DataFrame: + if entity_class == "unit": + return pl.DataFrame({"name": ["nuke", "gas"]}) + raise KeyError(entity_class) + def parameter(self, entity_class: str, name: str) -> pl.DataFrame: try: return self._params[(entity_class, name)] @@ -69,17 +80,55 @@ def _walk(commission_lf, entity: str = "nuke"): life_lf=None, factor_side=None, commission_lf=commission_lf) -def test_guard_raises_on_dtype_desync() -> None: - """Int64 d string-matches the Enum anchor '1' (pre-cast overlap > 0) - but casts to null against the Enum (post-cast survivors == 0) -> the - guard makes the silent-no-lag failure loud.""" - bad = pl.DataFrame({ +def _desync_commission() -> pl.LazyFrame: + """Int64 d whose string form matches the Enum anchor '1' (pre-cast + overlap > 0) but casts to null against the Enum (post-cast survivors + == 0) — the real dtype/calendar desync the guard exists to catch.""" + return pl.DataFrame({ "e": ["nuke"], "d": pl.Series("d", [1], dtype=pl.Int64), # string '1' == anchor "yr_c": [5.0], }).lazy() - with pytest.raises(ValueError, match="no surviving lag rows"): - _walk(bad).collect() + + +def test_guard_raises_on_dtype_desync() -> None: + """The guard makes the silent-no-lag failure loud, raising the DEDICATED + ``CommissioningLagError`` (not a bare ValueError that the downstream + ``except Exception`` swallows would silence).""" + with pytest.raises(CommissioningLagError, match="no surviving lag rows"): + _walk(_desync_commission()).collect() + + +def test_commissioning_lag_error_subclasses_value_error() -> None: + """CommissioningLagError <: ValueError so it can be raised from the + guards without churning any ``pytest.raises(ValueError)`` pins, while + its dedicated type lets the ``except Exception`` swallows re-raise it.""" + assert issubclass(CommissioningLagError, ValueError) + + +def test_guard_raises_through_real_builder(monkeypatch) -> None: + """When the §2.5 guard fires inside ``period_walk_iterator``, the + dedicated ``CommissioningLagError`` must PROPAGATE through the real + builder ``edd_invest_set_lf`` — the exact callable ``apply_derived_c`` + wraps in its ``try`` as ``_edd_invest_lf`` — so its narrow re-raise + (``except (LineageFilterError, CommissioningLagError): raise``) ahead of + the broad ``except Exception`` fires instead of silently reverting to + the unlagged set (MAJOR-1). + + The guard firing is modelled by patching ``period_walk_iterator`` (the + walker the builder calls, and where the real guard lives) to raise the + dedicated type: the builder has no swallow of its own, so the escape is + what lets ``apply_derived_c``'s narrow except catch and re-raise it.""" + def _raise_lag(*_a, **_k): + raise CommissioningLagError( + "commission_lf join produced no surviving lag rows") + monkeypatch.setattr( + "flextool.engine_polars._derived_existing.period_walk_iterator", + _raise_lag) + with pytest.raises(CommissioningLagError, match="no surviving lag rows"): + edd_invest_set_lf( + _source(), "s", _ed_lf("nuke"), PERIODS, PERIODS, + commission_lf=_desync_commission()).collect() def test_guard_does_not_raise_on_zero_overlap_cohort() -> None: @@ -108,3 +157,75 @@ def test_matching_commission_applies_lag_without_raising() -> None: }).lazy() out = _walk(good).collect() assert sorted(out["d_all"].cast(pl.Utf8).to_list()) == ["2", "3", "4"] + + +# --- MINOR-3: commissioning-helper failures must be loud when lag is set -- + +def _source_with_lead(lead: float, entity: str = "nuke") -> _StubSource: + """A stub source carrying a scalar ``construction_lead_time`` on the + unit class (so ``_construction_lag_configured`` sees a configured lag).""" + src = _source() + src._params[("unit", "construction_lead_time")] = pl.DataFrame( + {"name": [entity], "value": [float(lead)]}) + return src + + +def test_lag_configured_true_when_lead_set() -> None: + assert _construction_lag_configured(_source_with_lead(6.0)) is True + + +def test_lag_configured_false_no_lead() -> None: + assert _construction_lag_configured(_source()) is False + + +def test_lag_configured_false_zero_lead() -> None: + """L == 0 is the byte-parity no-lag case — must NOT count as configured + (else existing L=0 models would raise on a benign helper hiccup).""" + assert _construction_lag_configured(_source_with_lead(0.0)) is False + + +def _raise_runtime(*_a, **_k): + raise RuntimeError("simulated commissioning_year_lf bug") + + +def test_build_commission_lf_surfaces_helper_error_when_lag_configured( + monkeypatch) -> None: + """A helper bug on a genuinely lag-configured model (L>0) surfaces as a + CommissioningLagError instead of silently solving the unlagged model.""" + monkeypatch.setattr( + "flextool.engine_polars._derived_existing.commissioning_year_lf", + _raise_runtime) + with pytest.raises(CommissioningLagError, match="MINOR-3"): + _build_commission_lf( + _source_with_lead(6.0), "s", _ed_lf("nuke"), PERIODS, None) + + +def test_build_commission_lf_swallows_helper_error_when_no_lag( + monkeypatch) -> None: + """No lead configured -> the helper would legitimately return None, so a + failure is swallowed to None (byte-parity for existing models).""" + monkeypatch.setattr( + "flextool.engine_polars._derived_existing.commissioning_year_lf", + _raise_runtime) + assert _build_commission_lf( + _source(), "s", _ed_lf("nuke"), PERIODS, None) is None + + +def test_build_commission_lf_propagates_commissioning_lag_error( + monkeypatch) -> None: + """A CommissioningLagError from the helper is NEVER swallowed, even on a + no-lag source (the dedicated re-raise sits ahead of the broad except).""" + def _boom(*_a, **_k): + raise CommissioningLagError("desync from helper") + monkeypatch.setattr( + "flextool.engine_polars._derived_existing.commissioning_year_lf", + _boom) + with pytest.raises(CommissioningLagError, match="desync from helper"): + _build_commission_lf( + _source(), "s", _ed_lf("nuke"), PERIODS, None) + + +def test_build_commission_lf_none_when_no_periods() -> None: + """No in-use periods -> None (no build attempted), regardless of lag.""" + assert _build_commission_lf( + _source_with_lead(6.0), "s", _ed_lf("nuke"), [], None) is None From a47c218872512aca38ba4b699b4b2dcbc42ba5fb Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 30 Sep 2026 01:00:44 +0300 Subject: [PATCH 55/87] fix(invest): commissioning helper must not read stale recourse-walk globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slice H construction-lead-time STAGING tests failed in the full sweep (green in isolation) because the commissioning-year helper silently resolved its seam calendar from the WRONG solve. Root cause: commissioning_year_lf's [F2] fallback reads the boundary-scoped recourse walker process-globals (_RECOURSE_WALK_PROVIDER / _WORKDIR via _recourse_walk_provider()/_recourse_walk_workdir() in _derived_params). A real recourse solve arms those globals and leaves them ARMED when it finishes; in production the next solve's cascade clears them (_apply_db_overrides -> _reset_recourse_scope, the F1 guarantee). But a solver-free unit test that calls the helper (and period_walk_iterator) directly never runs that cascade, so on the same pytest worker it inherited a PRIOR recourse solve test's Provider + workdir and read that solve's p_years_d.csv. Its periods don't intersect the stub model's period_in_use -> the seam set collapses to empty -> every snap is null -> commission_lf comes back None -> the availability walk keeps yr_d -> capacity is available from the ORDER period (all periods serving) instead of the lagged commissioning period. The staging assertion serving == {"p1"} then failed with {"p1","p2","p3","p4"} — the lag silently vanished. ea3795a7 (which added tests to the guard file) merely re-bucketed the xdist loadfile distribution so the staging file co-located with a leaker (test_recourse_invest_{hedge,na,gate}); the latent leak was always present. Fix (two parts): * Test isolation — add an autouse conftest fixture that resets the recourse walker process-globals before/after every engine_polars test, mirroring the existing axis-enums ContextVar-leak reset and giving every test the same clean slate the F1 cascade already guarantees a real solve. This is the direct fix: helper AND walk both read the (now-clean) globals. * Loud-on-empty guard (belt-and-suspenders) — commissioning_year_lf now raises CommissioningLagError when a lag-configured model (_construction_lag_configured true) hits a calendar DESYNC: _p_years_d_lf returns None, or the seam set over period_in_use is EMPTY. This converts the dangerous silent-no-lag into a loud failure and closes the stale-but-wrong-provider hole the F3 join guard misses (empty overlap never fires F3). Keyed on the EMPTY-seam / no-calendar signal, NOT on emptiness alone, so legitimate immediate / round-to-zero models (whose seam set is non-empty and simply snap back onto the order period) keep returning None — byte-parity preserved (no-lag/immediate unchanged, zero golden changes). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0199XGsB22JSNNHH1cxcWbcQ --- flextool/engine_polars/_derived_existing.py | 34 +++++++++++++++++++++ tests/engine_polars/conftest.py | 27 ++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/flextool/engine_polars/_derived_existing.py b/flextool/engine_polars/_derived_existing.py index 4a6bf5df2..40381bc0b 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -401,9 +401,25 @@ def commissioning_year_lf(source: "InputSource", if provider is not None and workdir is None: workdir = _recourse_walk_workdir() + # [F2b] Whether this model genuinely configures a lag (some entity has + # ``construction_lead_time > 0``). Gates the loud-on-desync guards below + # so a no-lag model stays byte-identical (returns ``None`` silently) while + # a lag-configured model can never SILENTLY produce an empty commissioning + # frame from an unresolvable seam calendar — the exact silent-no-lag the + # Slice H guards exist to prevent (e.g. a stale recourse walker Provider + # resolving ``p_years_d.csv`` from the wrong solve → no in-use seam). + _lag_cfg = _construction_lag_configured(source) + from ._derived_params import _p_years_d_lf pyd_lf = _p_years_d_lf(source, active_solve, workdir, provider=provider) if pyd_lf is None: + if _lag_cfg: + from ._solve_state import CommissioningLagError + raise CommissioningLagError( + "commissioning_year_lf could not resolve a period-year " + "calendar (_p_years_d_lf returned None) on a model with a " + "configured construction_lead_time (> 0); refusing to " + "silently solve the unlagged model (Slice H F2b).") return None # Per-(e, o) lead time over the anchor grid, then resolve the method. @@ -437,6 +453,24 @@ def commissioning_year_lf(source: "InputSource", .unique() .sort("seam")) + # [F2b] An EMPTY seam set on a lag-configured model is a calendar + # DESYNC — none of the in-use periods resolved a year (e.g. the seam + # calendar came from the wrong solve's ``p_years_d.csv`` via a stale + # recourse walker Provider, whose periods don't intersect + # ``period_in_use``). Every snap would then collapse to null → an empty + # frame → the lag SILENTLY vanishes. This is distinct from a legitimate + # round-to-zero / immediate model, whose seam set is non-empty (the lag + # simply snaps back onto the order period). Surface it loudly. + if _lag_cfg and seams.select(pl.len()).collect().item() == 0: + from ._solve_state import CommissioningLagError + raise CommissioningLagError( + "commissioning_year_lf resolved an EMPTY seam set over " + "period_in_use on a model with a configured " + "construction_lead_time (> 0) — the period-year calendar does " + "not cover any in-use period (calendar desync, e.g. a stale " + "recourse walker Provider); refusing to silently solve the " + "unlagged model (Slice H F2b).") + inf = float("inf") work = (work .with_columns(t_star=pl.col("yr_o") + pl.col("L")) diff --git a/tests/engine_polars/conftest.py b/tests/engine_polars/conftest.py index 95da0c9e1..49cd152ca 100644 --- a/tests/engine_polars/conftest.py +++ b/tests/engine_polars/conftest.py @@ -61,3 +61,30 @@ def _reset_global_axis_enums(): set_global_axis_enums(None) yield set_global_axis_enums(None) + + +@pytest.fixture(autouse=True) +def _reset_recourse_walk_scope(): + """Reset the recourse walker process-globals before/after each test. + + :func:`._derived_params._enter_recourse_anchor_scope` arms three + module-globals for the active solve — the recourse anchor-pairs holder + and the boundary-scoped walker Provider + workdir (Slice D α-1). A real + recourse solve leaves them ARMED when it finishes; in production the next + solve's cascade clears them (``input._apply_db_overrides`` calls + :func:`._derived_params._reset_recourse_scope` first — the F1 guarantee). + + A solver-free unit test that calls ``period_walk_iterator`` / + ``commissioning_year_lf`` directly never runs that cascade, so without + this fixture it would inherit a PRIOR recourse solve test's Provider + + workdir on the same pytest worker and resolve the seam calendar from the + wrong solve's ``p_years_d.csv`` — silently producing an empty/garbage + commissioning frame (the construction-lead-time staging tests then see + the lag "vanish": all periods serving). Reset before AND after each test + to give every test the same clean slate the F1 cascade guarantees a real + solve. + """ + from flextool.engine_polars import _derived_params as _drv + _drv._reset_recourse_scope() + yield + _drv._reset_recourse_scope() From 5112e385e65cd22e34e9bde3ecd0b441bf8e171f Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 16:11:09 +0300 Subject: [PATCH 56/87] feat(reserve): load reserve_duration param (#322) Add reserve_duration scalar to reserve__upDown__group (schema parameter_definitions + parameter_types), plumb the loader (_direct_params from_source + wiring, FlexData field, _warm deferred + shape), and cover the helper with in-memory loader unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 9031ce2dcb82451b0317852dd368019be3088f98) --- flextool/engine_polars/_direct_params.py | 31 +++++++++++ flextool/engine_polars/_warm.py | 2 + flextool/engine_polars/input.py | 1 + flextool/schemas/spinedb_schema.json | 14 +++++ .../test_direct_params_delta4.py | 52 +++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/flextool/engine_polars/_direct_params.py b/flextool/engine_polars/_direct_params.py index cc2d97556..5a2b9bd89 100644 --- a/flextool/engine_polars/_direct_params.py +++ b/flextool/engine_polars/_direct_params.py @@ -1537,6 +1537,35 @@ def p_reserve_upDown_group_penalty_reserve_from_source(source: "InputSource") -> return Param(("r", "ud", "g"), out.lazy()) +def p_reserve_upDown_group_reserve_duration_from_source(source: "InputSource") -> Param | None: + """``reserve__upDown__group.reserve_duration`` scalar → + ``Param(("r", "ud", "g"))``. None default — explicit rows only. + + Hours over which committed reserve power must be sustainable from a + storage-backed provider. Absent (or 0) rows leave the LP untouched + (no storage-energy coupling) — see ``_reserve._add_storage_reserve_constraints``. + """ + try: + df = source.parameter_explicit("reserve__upDown__group", "reserve_duration") + except (KeyError, AttributeError): + try: + df = source.parameter("reserve__upDown__group", "reserve_duration") + except KeyError: + return None + if df is None or df.height == 0: + return None + cols = df.columns + if not {"reserve", "upDown", "group", "value"}.issubset(cols): + return None + lf = (df.lazy() + .rename({"reserve": "r", "upDown": "ud", "group": "g"}) + .filter(pl.col("value").is_not_null())) + out = lf.select("r", "ud", "g", "value").collect() + if out.height == 0: + return None + return Param(("r", "ud", "g"), out.lazy()) + + def _process_reserve_node_param(source: "InputSource", parameter_name: str) -> Param | None: """Union ``reserve__upDown__unit__node`` ∪ @@ -2126,6 +2155,8 @@ def apply_direct_params_b(source: "InputSource", flex_data.pdtReserve_upDown_group_reservation = v flex_data.p_reserve_upDown_group_penalty_reserve = ( p_reserve_upDown_group_penalty_reserve_from_source(source)) + flex_data.p_reserve_upDown_group_reserve_duration = ( + p_reserve_upDown_group_reserve_duration_from_source(source)) flex_data.p_process_reserve_upDown_node_reliability = ( p_process_reserve_upDown_node_reliability_from_source(source)) flex_data.p_process_reserve_upDown_node_max_share = ( diff --git a/flextool/engine_polars/_warm.py b/flextool/engine_polars/_warm.py index 8c771dd48..1a34f1a8e 100644 --- a/flextool/engine_polars/_warm.py +++ b/flextool/engine_polars/_warm.py @@ -321,6 +321,7 @@ # Reserves. "pdtReserve_upDown_group_reservation", "p_reserve_upDown_group_penalty_reserve", + "p_reserve_upDown_group_reserve_duration", "p_process_reserve_upDown_node_reliability", "p_process_reserve_upDown_node_max_share", "p_process_reserve_upDown_node_large_failure_ratio_value", @@ -430,6 +431,7 @@ # Reserves. "pdtReserve_upDown_group_reservation": ("reserve_upDown_group",), "p_reserve_upDown_group_penalty_reserve": ("reserve_upDown_group",), + "p_reserve_upDown_group_reserve_duration": ("reserve_upDown_group",), "p_process_reserve_upDown_node_reliability": ("prundt",), "p_process_reserve_upDown_node_max_share": ("prundt",), "p_process_reserve_upDown_node_large_failure_ratio_value": diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 6a078c49a..0f4db3389 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -825,6 +825,7 @@ class FlexData: p_process_reserve_upDown_node_reliability: Param | None = None # (p, r, ud, n) pdtReserve_upDown_group_reservation: Param | None = None # (r, ud, g, d, t) p_reserve_upDown_group_penalty_reserve: Param | None = None # (r, ud, g) + p_reserve_upDown_group_reserve_duration: Param | None = None # (r, ud, g) p_process_reserve_upDown_node_max_share: Param | None = None # (p, r, ud, n) p_process_reserve_upDown_node_large_failure_ratio_value: Param | None = None # (p, r, ud, n) p_process_reserve_upDown_node_increase_reserve_ratio_value: Param | None = None # (p, r, ud, n) diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index 9a61f0416..cabb66e53 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -2096,6 +2096,14 @@ "[MW] Amount of reserve to be reserved. Constant, period, time, period+time or stochastic time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -4068,6 +4076,12 @@ "map", 3 ], + [ + "reserve__upDown__group", + "reserve_duration", + "float", + 0 + ], [ "reserve__upDown__group", "reserve_method", diff --git a/tests/engine_polars/test_direct_params_delta4.py b/tests/engine_polars/test_direct_params_delta4.py index 6d09e7da4..2c518424a 100644 --- a/tests/engine_polars/test_direct_params_delta4.py +++ b/tests/engine_polars/test_direct_params_delta4.py @@ -138,6 +138,58 @@ def test_p_commodity_unitsize_inmemory_explicit(): assert p.frame.sort("c")["value"].to_list() == pytest.approx([2.0]) +def test_p_reserve_duration_inmemory_explicit(): + """Explicit ``reserve_duration`` row → + ``Param(("r", "ud", "g"), [r, ud, g, value])``.""" + src = InMemoryReader( + entities={"reserve__upDown__group": pl.DataFrame({ + "reserve": ["primary"], "upDown": ["up"], "group": ["elecG"], + })}, + parameters={ + ("reserve__upDown__group", "reserve_duration"): pl.DataFrame({ + "reserve": ["primary"], "upDown": ["up"], "group": ["elecG"], + "value": [0.5], + }), + }, + ) + p = dp.p_reserve_upDown_group_reserve_duration_from_source(src) + assert isinstance(p, Param) + assert p.dims == ("r", "ud", "g") + got = p.frame.sort("r", "ud", "g") + assert got["r"].to_list() == ["primary"] + assert got["ud"].to_list() == ["up"] + assert got["g"].to_list() == ["elecG"] + assert got["value"].to_list() == pytest.approx([0.5]) + + +def test_p_reserve_duration_inmemory_absent(): + """No ``reserve_duration`` parameter at all → helper returns None + (byte-identical LP: the storage-coupling constraints are skipped).""" + src = InMemoryReader( + entities={"reserve__upDown__group": pl.DataFrame({ + "reserve": ["primary"], "upDown": ["up"], "group": ["elecG"], + })}, + parameters={}, + ) + assert dp.p_reserve_upDown_group_reserve_duration_from_source(src) is None + + +def test_p_reserve_duration_inmemory_empty_rows(): + """Header-only ``reserve_duration`` frame → helper returns None.""" + src = InMemoryReader( + entities={"reserve__upDown__group": pl.DataFrame({ + "reserve": ["primary"], "upDown": ["up"], "group": ["elecG"], + })}, + parameters={ + ("reserve__upDown__group", "reserve_duration"): pl.DataFrame( + schema={"reserve": pl.Utf8, "upDown": pl.Utf8, + "group": pl.Utf8, "value": pl.Float64}, + ), + }, + ) + assert dp.p_reserve_upDown_group_reserve_duration_from_source(src) is None + + # --------------------------------------------------------------------------- # Fixture-driven CSV vs DB-direct frame parity. From 5303f09f0ededfe5a2240cb95dbe9e0d382e1ecc Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 16:42:35 +0300 Subject: [PATCH 57/87] feat(reserve): storage energy-adequacy constraints for reserve_duration (#322) Add reserve_storage_up_floor / reserve_storage_down_headroom in _reserve._add_storage_reserve_constraints: a storage-backed reserve provider must hold enough stored energy (up/discharge) or free headroom (down/charge) to sustain committed reserve MW for reserve_duration hours. Efficiency-corrected via p_slope (coalesced to 1.0 over the provider set), reliability excluded, ud pre-filtered before the topology join. Down headroom replicates maxState's invest/divest cross-Enum capacity block scaled by p_state_unitsize. No slack: with v_reserve=0 both reduce to existing bounds, so a short battery just commits less reserve and pays the vq_reserve penalty. Absent reserve_duration => byte-identical LP. Dispatch wiring threads v_state/v_invest_n/v_divest_n into _reserve (gated on has_storage/has_invest_n/has_divest_n). Registers the two ENERGY constraint families + the DURATION param in the autoscale registries. Synthetic engine tests (both directions, efficiency, connection provider, no-op, feasibility) run under FLEXTOOL_AUTOSCALE_STRICT=1; real-SpineDB loader e2e; docs. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 054b7a47bac8490a7f56d9a0e471a3388f0c9ee1) --- docs/how_to.md | 2 + docs/reference.md | 38 +++ flextool/engine_polars/_reserve.py | 205 +++++++++++++++ .../engine_polars/autoscale/_layer2_types.py | 5 + .../autoscale/_quantity_types.py | 2 + flextool/engine_polars/model.py | 10 + .../fixtures/flex_toy_reserve_storage.py | 242 ++++++++++++++++++ .../loaders/test_reserve_duration_db_e2e.py | 89 +++++++ .../test_flex_toy_reserve_storage.py | 166 ++++++++++++ 9 files changed, 759 insertions(+) create mode 100644 tests/engine_polars/fixtures/flex_toy_reserve_storage.py create mode 100644 tests/engine_polars/loaders/test_reserve_duration_db_e2e.py create mode 100644 tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py diff --git a/docs/how_to.md b/docs/how_to.md index 115603d15..1d7f9fcef 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -633,6 +633,8 @@ Next, the reserve requirement will be defined. An entity between the `reserve__u Parameters from the `reserve__upDown__unit__node` class should be used to define how different units can contribute to different reserves. Note that the entities in this class need to be added to the `Entity Alternative` sheet. Parameter `max_share` says how large share of the total capacity of the timestep (existing * efficiency * (profile)) of the unit can contribute to this reserve category (e.g. *coal_plant* may be limited by ramp constraint to provide only 1% of its capacity to an upward primary reserve.) Meanwhile, parameter `reliability` affects what portion of the reserved capacity actually contributes to the reserve (e.g. *wind_plant* may contribute only 80% of its generation to reserve due to uncertainty). +If the reserve is provided by a **battery** or other storage (a unit discharging from, or a connection/charger tied to, a storage node), set `reserve_duration` (hours) on the `reserve__upDown__group` entity so the storage actually holds enough energy to sustain the committed reserve power. For example, a 4-hour upward reserve from a battery uses `reserve_duration = 4`: the model then guarantees the battery's stored energy can back its reserve commitment for 4 hours (corrected by the round-trip efficiency). Without `reserve_duration` the reserve is instantaneous-power-only — the battery could commit reserve it has no energy to deliver. The coupling only applies to storage-backed providers; ordinary fuel-fed units are unaffected. + ![Add a reserve](./img/concept/reserves.png) diff --git a/docs/reference.md b/docs/reference.md index 9f34e6896..830a10489 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -525,6 +525,44 @@ For `reserve__upDown__group` entities: - `reservation` - [MW] Amount of reserve required. Constant or time. - `penalty_reserve` - [CUR/MW] Penalty cost for not fulfilling the reserve requirement. Constant. - `increase_reserve_ratio` - [factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant. +- `reserve_duration` - [h] Time span over which committed reserve power must be sustainable from storage. Constant. Absent or 0 means the reserve is instantaneous-power-only (legacy behaviour); no storage-energy coupling is added. + +#### Storage-backed reserve energy adequacy (`reserve_duration`) + +By default a reserve reservation only withholds *power* (MW of headroom) from +the provider. `reserve_duration` additionally requires the provider to hold +enough *energy* to actually sustain that committed power for the given number +of hours, when the provider draws on (upward reserve) or charges into +(downward reserve) a storage node. This is a single-scalar simplification of +Backbone's duty-cycle formulation (self-discharge over the window is ignored +in this first version). + +For each committed reserve MW the required energy is `reservation_MW × +reserve_duration`, corrected by the provider's efficiency: + +- **Upward / discharge** — the storage node is the provider's *source*, so the + energy drawn is divided by the efficiency (`÷ η`): a lossy discharge must + draw more stored energy to deliver the same MW. The stored energy floor is + `v_state · unitsize ≥ Σ reservation_MW · reserve_duration / η`. +- **Downward / charge** — the storage node is the provider's *sink*, so the + energy stored is multiplied by the efficiency (`× η`): a lossy charge stores + less per MW of charging. The free-headroom bound is + `v_state · unitsize + Σ reservation_MW · reserve_duration · η ≤ capacity` + (with the same invest/divest capacity replication as the storage state + bound). + +The coupling is storage-only: reserve provided by non-storage units (e.g. a +gas turbine drawing on a fuel commodity) is unaffected. The obligation uses the +provider's full committed MW — the trust derating (`reliability`) is +deliberately excluded, since the physical energy required is independent of how +much of the reservation is counted towards the requirement. + +Limitation under representative periods: the constraints reference the +intra-representative-period storage state (`v_state`) only. Under the +representative-period *blended-weights* storage modes the seasonal inter-period +state is bounded separately and is not aligned to the reserve's time index, so +`reserve_duration` couples reserve to intra-period state only — matching +FlexTool's own intra-period capacity approximation. ### Reserve provision by units diff --git a/flextool/engine_polars/_reserve.py b/flextool/engine_polars/_reserve.py index bb3dd8347..7b96bf9fc 100644 --- a/flextool/engine_polars/_reserve.py +++ b/flextool/engine_polars/_reserve.py @@ -85,6 +85,7 @@ def _provider_get(provider, path: "Path") -> "pl.DataFrame | None": "p_process_reserve_upDown_node_reliability", # (p, r, ud, n) — already coalesced "pdtReserve_upDown_group_reservation", # (r, ud, g, d, t) — RHS demand "p_reserve_upDown_group_penalty_reserve", # (r, ud, g) — objective penalty + "p_reserve_upDown_group_reserve_duration", # (r, ud, g) — storage-energy coupling (#322) ) @@ -614,6 +615,210 @@ def add_constraints(m, d, vars: dict) -> None: rhs_terms = {"max": rhs_param}, ) + # ── Storage energy adequacy for reserve_duration (#322) ────────────── + _add_storage_reserve_constraints(m, d, vars) + + +# --------------------------------------------------------------------------- +# Storage energy-adequacy coupling (reserve_duration, issue #322) + +def _add_storage_reserve_constraints(m, d, vars: dict) -> None: + """Couple committed reserve MW to stored energy over ``reserve_duration``. + + A storage-backed reserve provider must physically hold enough stored + energy (UP / discharge) or free charging headroom (DOWN / charge) to + sustain its committed reserve power for ``reserve_duration`` hours. + Two standalone constraints, both framed in MWh: + + * ``reserve_storage_up_floor`` (>=): ``v_state·unitsize − E_up ≥ 0`` + * ``reserve_storage_down_headroom`` (<=): ``v_state·unitsize + E_dn + (+ divest − invest)·unitsize ≤ p_state_upper·unitsize`` + + where, keyed on the storage node ``n`` (after collapsing over the + provider axes ``p, r, ud, nb, g``): + + * ``E_up = Σ v_reserve·p_unitsize·dur·p_slope`` (energy DRAWN — the + provider's storage is the SOURCE, so multiply by the slope 1/η), + * ``E_dn = Σ v_reserve·p_unitsize·dur/p_slope`` (energy STORED — the + provider's storage is the SINK, so divide by the slope, i.e. ×η). + + ``dur = p_reserve_upDown_group_reserve_duration[r,ud,g]`` (hours). + Reserve provision spans BOTH units and connections (``process_source_sink`` + is class-agnostic). ``ud`` is pre-filtered ('up' for the floor, 'down' + for the headroom) BEFORE the topology join so a reversible battery's two + orientations don't cross-contaminate. ``p_slope`` is coalesced to 1.0 + over the provider set (noEff providers are absent from ``p_slope``; an + unfilled null would silently drop the constraint). + + reliability is DELIBERATELY EXCLUDED: the physical energy a provider must + hold equals its full committed MW, independent of the trust derating that + the reserve-balance LHS applies. + + No slack variable is added. With ``v_reserve = 0`` both constraints + reduce to already-enforced bounds (``v_state ≥ 0`` and ``maxState``), so a + provider that cannot sustain the duration simply commits less reserve and + pays the existing ``vq_reserve`` shortfall penalty — feasibility is never + lost. Absent/empty ``reserve_duration`` ⇒ neither constraint is emitted + (byte-identical LP). + + # NOTE(reserve_duration RP): both constraints reference ``v_state`` alone + # (× ``p_state_unitsize``). Under RP *_blended_weights modes the seasonal + # ``v_state_inter[n,b]`` is bounded separately and indexed by base period + # ``b`` (not alignable to reserve's ``(d,t)``), so this couples reserve to + # INTRA-representative-period state only — a known limitation matching + # FlexTool's own intra-period capacity approximation. Revisit if/when a + # seasonal-state alignment becomes expressible. + """ + dur = getattr(d, "p_reserve_upDown_group_reserve_duration", None) + if dur is None or not has_feature(d): + return + if d.nodeState is None or d.nodeState.height == 0: + return + if d.process_source_sink is None or d.process_source_sink.height == 0: + return + v_state = vars.get("v_state") + v_reserve = vars.get("v_reserve") + us_n = d.p_state_unitsize + if v_state is None or v_reserve is None or us_n is None: + return + + p_unitsize = d.p_unitsize + rug = d.reserve_upDown_group # (r, ud, g) + pruna = d.process_reserve_upDown_node_active # (p, r, ud, n) + gn = d.group_node.pipe(rename_to_axis, {"n": "n"}) # (g, n) — balance node + dur_set = dur.frame.select("r", "ud", "g") + dt = d.dt + + def _build_join(ud_val: str, balance_pss: str, storage_pss: str): + """Provider→storage-node topology join. Returns a frame with + columns ``(p, r, ud, nb, n, g)`` where ``nb`` = the balance + (reserve) node and ``n`` = the storage node, or ``None`` when + empty. Mirrors the n-1 RHS relabel and ``pruna_g`` group join.""" + pr = pruna.filter(pl.col("ud") == ud_val) + if pr.height == 0: + return None + # Cast the balance node to canonical "n" for the pruna join. + j = (d.process_source_sink + .pipe(rename_to_axis, {balance_pss: "n"}) + .join(pr, on=["p", "n"], how="inner")) # n = balance node + if j.height == 0: + return None + # balance n → nb (node enum preserved by plain rename); + # storage side → canonical "n" (node-enum cast via rename_to_axis). + j = (j.rename({"n": "nb"}) + .pipe(rename_to_axis, {storage_pss: "n"})) + j = (j.join(gn.rename({"n": "nb"}), on="nb", how="inner") # adds g + .join(rug, on=["r", "ud", "g"], how="inner") + .join(dur_set, on=["r", "ud", "g"], how="inner") # absent dur ⇒ empty + .join(d.nodeState.select("n"), on="n", how="semi") # storage ∈ nodeState + .select("p", "r", "ud", "nb", "n", "g") + .unique()) + return j if j.height > 0 else None + + def _v_reserve_nb() -> "Var": + """``v_reserve`` view with its balance axis ``n`` relabelled to + ``nb`` so the storage node ``n`` (added by the Where join) is the + only ``n`` that survives the collapse — aligning with ``v_state``. + """ + dims = tuple("nb" if c == "n" else c for c in v_reserve.dims) + return Var(name=v_reserve.name, dims=dims, + frame=v_reserve.frame.rename({"n": "nb"})) + + def _slope_factor(providers: "pl.DataFrame", reciprocal: bool) -> Param: + """Total ``Param(("p","d","t"))`` slope factor over the provider + set × dt — coalesced to 1.0 where ``p_slope`` is absent (noEff + providers) so the energy product never silently drops a row. + ``reciprocal`` ⇒ ×(1/slope) = ×η for the DOWN / charge direction.""" + grid = providers.join(dt, how="cross") # (p, d, t) + if d.p_slope is not None: + grid = grid.join(d.p_slope.frame, on=["p", "d", "t"], how="left") + val = pl.col("value").fill_null(1.0) + else: + val = pl.lit(1.0) + if reciprocal: + val = 1.0 / val + frame = grid.with_columns(value=val).select("p", "d", "t", "value") + return Param(("p", "d", "t"), frame) + + v_res_nb = _v_reserve_nb() + + # ── UP / discharge floor: v_state·unitsize ≥ E_up ──────────────────── + join_up = _build_join("up", balance_pss="sink", storage_pss="source") + if join_up is not None: + slope_up = _slope_factor(join_up.select("p").unique(), reciprocal=False) + e_up = Sum( + Where(v_res_nb * p_unitsize * dur * slope_up, join_up), + over=("p", "r", "ud", "nb", "g"), + ) + over_up = join_up.select("n").unique().join(dt, how="cross") + m.add_cstr( + "reserve_storage_up_floor", + over = over_up, + sense = ">=", + lhs_terms = {"state": v_state * us_n, "energy_neg": -e_up}, + rhs_terms = {"zero": 0.0}, + ) + + # ── DOWN / charge headroom: v_state·unitsize + E_dn ≤ cap·unitsize ─── + join_dn = _build_join("down", balance_pss="source", storage_pss="sink") + if join_dn is not None: + slope_dn = _slope_factor(join_dn.select("p").unique(), reciprocal=True) + e_dn = Sum( + Where(v_res_nb * p_unitsize * dur * slope_dn, join_dn), + over=("p", "r", "ud", "nb", "g"), + ) + over_dn = join_dn.select("n").unique().join(dt, how="cross") + state_lhs: dict = {"state": v_state * us_n, "energy": e_dn} + # Invest/divest headroom replication — mirrors maxState EXACTLY + # (flextool/engine_polars/model.py:3523-3559), scaled by + # p_state_unitsize (→ MWh). Gated on has_invest_n / has_divest_n + # (presence in ``vars``). With neither, RHS is just cap·unitsize. + v_invest_n = vars.get("v_invest_n") + v_divest_n = vars.get("v_divest_n") + if v_divest_n is not None and d.edd_divest_active is not None: + v_div_n_at = Var( + name=v_divest_n.name + "__at_reserve_down_divest", + dims=("n", "d_divest"), + frame=v_divest_n.frame.pipe(rename_to_axis, {"d": "d_divest"}), + lower=v_divest_n.lower, upper=v_divest_n.upper, + ) + # Cross-Enum is_in (p-axis vs n-axis vocab); up-cast n→p. + _p_dt = d.edd_divest_active.schema["p"] + _n_in_p = (d.nodeState + .select(pl.col("n").cast(_p_dt, strict=False).alias("p")) + .unique()) + edd_div_n = (d.edd_divest_active + .join(_n_in_p, on="p", how="semi") + .pipe(rename_to_axis, {"p": "n"})) + if edd_div_n.height > 0: + state_lhs["divest"] = Sum( + Where(v_div_n_at, edd_div_n), over=("d_divest",)) * us_n + if v_invest_n is not None and d.edd_invest_set is not None: + v_inv_n_at = Var( + name=v_invest_n.name + "__at_reserve_down_invest", + dims=("n", "d_invest"), + frame=v_invest_n.frame.pipe(rename_to_axis, {"d": "d_invest"}), + lower=v_invest_n.lower, upper=v_invest_n.upper, + ) + # Cross-Enum is_in (e-axis vs n-axis vocab); up-cast n→e. + _e_dt = d.edd_invest_set.schema["e"] + _n_in_e = (d.nodeState + .select(pl.col("n").cast(_e_dt, strict=False).alias("e")) + .unique()) + edd_inv_n = (d.edd_invest_set + .join(_n_in_e, on="e", how="semi") + .pipe(rename_to_axis, {"e": "n"})) + if edd_inv_n.height > 0: + state_lhs["invest_neg"] = -(Sum( + Where(v_inv_n_at, edd_inv_n), over=("d_invest",)) * us_n) + m.add_cstr( + "reserve_storage_down_headroom", + over = over_dn, + sense = "<=", + lhs_terms = state_lhs, + rhs_terms = {"upper": d.p_state_upper * us_n}, + ) + # --------------------------------------------------------------------------- # Objective diff --git a/flextool/engine_polars/autoscale/_layer2_types.py b/flextool/engine_polars/autoscale/_layer2_types.py index c96c3da19..66a9fa536 100644 --- a/flextool/engine_polars/autoscale/_layer2_types.py +++ b/flextool/engine_polars/autoscale/_layer2_types.py @@ -447,6 +447,11 @@ class CstrFamily: # flextool/engine_polars/_reserve.py:576 (reserve_process_upward / _downward) "reserve_process_upward": CstrFamily(QuantityType.POWER), "reserve_process_downward": CstrFamily(QuantityType.POWER), + # Storage energy-adequacy coupling for reserve_duration (#322). Both + # framed in MWh: v_state·unitsize (± E) vs a stored-energy bound. + # flextool/engine_polars/_reserve.py _add_storage_reserve_constraints + "reserve_storage_up_floor": CstrFamily(QuantityType.ENERGY), + "reserve_storage_down_headroom": CstrFamily(QuantityType.ENERGY), # ── Group slack constraints. # flextool/engine_polars/_group_slack.py:859 diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 4af0dab7a..4b5a95939 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -451,6 +451,8 @@ class QuantityType(Enum): # [factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant. ('reservation', 'reserve__upDown__group'): QuantityType.POWER, # [MW] Amount of reserve to be reserved. Constant or time. + ('reserve_duration', 'reserve__upDown__group'): QuantityType.DURATION, + # [h] Time span over which committed reserve power must be sustainable from storage. Constant. ('reserve_method', 'reserve__upDown__group'): QuantityType.DIMENSIONLESS, # Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failur... ('retire_max_period', 'connection'): QuantityType.POWER, diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 3c915bb53..d5dceca1a 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -4434,6 +4434,16 @@ def _wt(param): res_vars["v_invest_p"] = v_invest_p if has_divest_p: res_vars["v_divest_p"] = v_divest_p + # Storage energy-adequacy coupling for reserve_duration (#322): + # v_state precedes this dispatch site (declared above), so threading + # it (+ v_invest_n / v_divest_n for the down-headroom capacity + # replication) is order-safe. Each gated on its own has_* flag. + if has_storage: + res_vars["v_state"] = v_state + if has_invest_n: + res_vars["v_invest_n"] = v_invest_n + if has_divest_n: + res_vars["v_divest_n"] = v_divest_n _reserve.add_constraints(m, d, res_vars) res_obj = _reserve.add_objective_terms(m, d, res_vars, op_factor) if res_obj is not None: diff --git a/tests/engine_polars/fixtures/flex_toy_reserve_storage.py b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py new file mode 100644 index 000000000..29fbadac8 --- /dev/null +++ b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py @@ -0,0 +1,242 @@ +"""Synthetic minimal-feature toy: storage-backed reserve with +``reserve_duration`` energy adequacy (issue #322). + +Extends ``flex_toy_reserve.py`` with a storage node ``bat`` (nodeState) +and a storage-coupled reserve provider, so the two new constraints in +``_reserve._add_storage_reserve_constraints`` bind: + + * ``reserve_storage_up_floor`` — UP / discharge floor + * ``reserve_storage_down_headroom`` — DOWN / charge headroom + +Topology (``direction="up"``) +----------------------------- +* nodeBalance nodes ``elec`` (reserve node) and ``bat`` (storage node), + both with zero inflow (dispatch is trivial — the only driver is the + reserve requirement). +* Storage node ``bat``: nodeState, ``p_state_unitsize = 1``, + ``p_state_upper = cap`` (var-units), no binding method (``v_state`` + free in ``[0, cap]``, uncosted). +* Provider process ``dis``: source=``bat``, sink=``elec``, eff partition, + ``p_unitsize = 100`` MW, slope ``= 1/eta``. Provides UP reserve at + ``elec`` → ``bat`` is its storage source, so the up-floor couples + ``v_reserve`` to ``v_state``. +* Reserve ``(r1, up, g)``, method ``timeseries``, reservation 50 MW, + ``penalty_reserve = 1000``, ``reserve_duration = duration`` (h). + +Closed form (``direction="up"``, ``eta=1``, ``cap=20``, ``duration=0.5``) +------------------------------------------------------------------------ +Energy floor: v_reserve·100·duration·slope ≤ v_state·1 ≤ cap·1 = 20. + ⇒ v_reserve ≤ cap / (100·duration·slope) = 20 / (100·0.5·1) = 0.4. +reserveBalance (>=): v_reserve·100·1 + vq·50 ≥ 50 + ⇒ 40 + vq·50 ≥ 50 ⇒ vq = 0.2. +Raising duration 0.5→1.0 halves the energy allowance: + v_reserve ≤ 0.2, vq = 0.6 — reserve tightens, slack rises. + +Topology (``direction="down"``) +------------------------------- +* Provider ``chg``: source=``elec``, sink=``bat`` (charge). Provides + DOWN reserve at ``elec``; ``bat`` is its storage sink, so the + down-headroom couples committed charge power to free capacity. +* Efficiency divides (×η): E_dn = v_reserve·100·duration·(1/slope) = + v_reserve·100·duration·eta. With ``eta=0.9``, ``duration=0.5``, + ``cap=20`` and ``v_state → 0`` (LP maximises headroom): + v_reserve·100·0.5·0.9 ≤ 20 ⇒ v_reserve ≤ 0.4444, vq = 0.1111. +""" +from __future__ import annotations + +import polars as pl +from polar_high import Param +from flextool.engine_polars.input import FlexData + + +_RESERVATION = 50.0 +_UNITSIZE = 100.0 # provider p_unitsize (MW) +_US_BAT = 1.0 # storage p_state_unitsize (MWh / var-unit) +_PENALTY = 1000.0 + + +def data(*, direction: str = "up", duration: float | None = 0.5, + eta: float = 1.0, cap: float = 20.0, + provider: str = "prov") -> FlexData: + """Build the storage-backed reserve toy. + + Parameters + ---------- + direction : {"up", "down"} + UP → discharge provider (source=bat); DOWN → charge provider + (source=elec, sink=bat). + duration : float | None + ``reserve_duration`` in hours. ``None`` omits the parameter + entirely (no-op guard — the two coupling constraints are not + emitted and the LP is byte-identical to the pre-feature build). + eta : float + Provider efficiency; slope = 1/eta. + cap : float + Storage ``p_state_upper`` in var-units (max stored energy = cap · + p_state_unitsize MWh). + """ + ud = "up" if direction == "up" else "down" + slope = 1.0 / eta + + # ── Time ────────────────────────────────────────────────────────── + dt = pl.DataFrame({"d": ["p2020", "p2020"], "t": ["t01", "t02"]}) + p_step_duration = Param(("d", "t"), + pl.DataFrame({"d": ["p2020"] * 2, "t": ["t01", "t02"], "value": [1.0, 1.0]})) + p_timestep_weight = Param(("d", "t"), + pl.DataFrame({"d": ["p2020"] * 2, "t": ["t01", "t02"], "value": [1.0, 1.0]})) + p_inflation_op = Param(("d",), pl.DataFrame({"d": ["p2020"], "value": [1.0]})) + p_period_share = Param(("d",), pl.DataFrame({"d": ["p2020"], "value": [1.0]})) + + # ── Nodes (both zero-inflow; reserve is the only driver) ────────── + nodeBalance = pl.DataFrame({"n": ["elec", "bat"]}) + nodeBalance_dt = nodeBalance.join(dt, how="cross") + p_inflow = Param(("n", "d", "t"), + nodeBalance_dt.with_columns(value=pl.lit(0.0)).select("n", "d", "t", "value")) + p_penalty_up = Param(("n", "d", "t"), + nodeBalance_dt.with_columns(value=pl.lit(1e6)).select("n", "d", "t", "value")) + p_penalty_down = Param(("n", "d", "t"), + nodeBalance_dt.with_columns(value=pl.lit(1e6)).select("n", "d", "t", "value")) + + # ── Process topology ────────────────────────────────────────────── + if direction == "up": + src, snk = "bat", "elec" # discharge: bat → elec + else: + src, snk = "elec", "bat" # charge: elec → bat + pss = pl.DataFrame({"p": [provider], "source": [src], "sink": [snk]}) + pss_eff = pss.clone() + pss_noEff = pl.DataFrame(schema={"p": pl.Utf8, "source": pl.Utf8, "sink": pl.Utf8}) + pss_dt = pss.join(dt, how="cross") + flow_to_n = pss.with_columns(n=pl.col("sink")) + # Source draws from a nodeBalance node (bat for up, elec for down). + flow_from_nodeBalance_eff = pss.clone() + flow_from_nodeBalance_noEff = pl.DataFrame( + schema={"p": pl.Utf8, "source": pl.Utf8, "sink": pl.Utf8}) + # No commodity flows. + flow_from_commodity_eff = pl.DataFrame( + schema={"p": pl.Utf8, "source": pl.Utf8, "sink": pl.Utf8, "c": pl.Utf8}) + flow_from_commodity_noEff = flow_from_commodity_eff.clone() + + p_unitsize = Param(("p",), pl.DataFrame({"p": [provider], "value": [_UNITSIZE]})) + p_flow_upper = Param(("p", "source", "sink", "d", "t"), + pss_dt.with_columns(value=pl.lit(1.0)) + .select("p", "source", "sink", "d", "t", "value")) + p_slope = Param(("p", "d", "t"), + dt.with_columns(p=pl.lit(provider), value=pl.lit(slope)) + .select("p", "d", "t", "value")) + p_commodity_price = Param(("c", "d", "t"), + pl.DataFrame(schema={"c": pl.Utf8, "d": pl.Utf8, "t": pl.Utf8, + "value": pl.Float64})) + + # ── Storage node ``bat`` ────────────────────────────────────────── + nodeState = pl.DataFrame({"n": ["bat"]}) + nodeState_dt = nodeState.join(dt, how="cross") + nodeState_first_dt = (nodeState_dt.sort(["n", "d", "t"]) + .group_by(["n", "d"], maintain_order=True) + .first().select("n", "d", "t")) + nodeState_last_dt = (nodeState_dt.sort(["n", "d", "t"]) + .group_by(["n", "d"], maintain_order=True) + .last().select("n", "d", "t")) + p_state_unitsize = Param(("n",), + pl.DataFrame({"n": ["bat"], "value": [_US_BAT]})) + p_state_upper = Param(("n", "d"), + pl.DataFrame({"n": ["bat"], "d": ["p2020"], "value": [cap]})) + p_state_self_discharge = Param(("n",), + pl.DataFrame({"n": ["bat"], "value": [0.0]})) + p_state_existing_capacity = Param(("n", "d"), + pl.DataFrame({"n": ["bat"], "d": ["p2020"], "value": [cap * _US_BAT]})) + # Cyclic step_previous over the 2 timesteps within p2020. + dtttdt = pl.DataFrame({ + "d": ["p2020", "p2020"], + "t": ["t01", "t02"], + "t_previous": ["t02", "t01"], + "t_previous_within_timeset": ["t02", "t01"], + "d_previous": ["p2020", "p2020"], + "t_previous_within_solve": ["t02", "t01"], + }) + + # ── Reserve data ────────────────────────────────────────────────── + reserve_upDown_group = pl.DataFrame({"r": ["r1"], "ud": [ud], "g": ["g"]}) + reserve_upDown_group_method_timeseries = pl.DataFrame( + {"r": ["r1"], "ud": [ud], "g": ["g"], "method": ["timeseries"]}) + reserve_upDown_group_method_dynamic = pl.DataFrame( + schema={"r": pl.Utf8, "ud": pl.Utf8, "g": pl.Utf8, "method": pl.Utf8}) + reserve_upDown_group_method_n_1 = pl.DataFrame( + schema={"r": pl.Utf8, "ud": pl.Utf8, "g": pl.Utf8, "method": pl.Utf8}) + + prundt = pl.DataFrame({ + "p": [provider] * 2, "r": ["r1"] * 2, "ud": [ud] * 2, + "n": ["elec"] * 2, "d": ["p2020"] * 2, "t": ["t01", "t02"], + }) + process_reserve_upDown_node_active = pl.DataFrame( + {"p": [provider], "r": ["r1"], "ud": [ud], "n": ["elec"]}) + group_node = pl.DataFrame({"g": ["g"], "n": ["elec"]}) + + p_process_reserve_upDown_node_reliability = Param(("p", "r", "ud", "n"), + pl.DataFrame({"p": [provider], "r": ["r1"], "ud": [ud], "n": ["elec"], + "value": [1.0]})) + pdtReserve_upDown_group_reservation = Param(("r", "ud", "g", "d", "t"), + pl.DataFrame({"r": ["r1"] * 2, "ud": [ud] * 2, "g": ["g"] * 2, + "d": ["p2020"] * 2, "t": ["t01", "t02"], + "value": [_RESERVATION, _RESERVATION]})) + p_reserve_upDown_group_penalty_reserve = Param(("r", "ud", "g"), + pl.DataFrame({"r": ["r1"], "ud": [ud], "g": ["g"], "value": [_PENALTY]})) + p_process_reserve_upDown_node_max_share = Param(("p", "r", "ud", "n"), + pl.DataFrame({"p": [provider], "r": ["r1"], "ud": [ud], "n": ["elec"], + "value": [1.0]})) + p_process_existing_count = Param(("p", "d"), + pl.DataFrame({"p": [provider], "d": ["p2020"], "value": [1.0]})) + + kwargs = dict( + dt=dt, + p_step_duration=p_step_duration, + p_timestep_weight=p_timestep_weight, + p_inflation_op=p_inflation_op, + p_period_share=p_period_share, + nodeBalance=nodeBalance, + nodeBalance_dt=nodeBalance_dt, + p_inflow=p_inflow, + p_penalty_up=p_penalty_up, + p_penalty_down=p_penalty_down, + process_source_sink=pss, + process_source_sink_eff=pss_eff, + process_source_sink_noEff=pss_noEff, + pss_dt=pss_dt, + flow_to_n=flow_to_n, + flow_from_nodeBalance_eff=flow_from_nodeBalance_eff, + flow_from_nodeBalance_noEff=flow_from_nodeBalance_noEff, + flow_from_commodity_eff=flow_from_commodity_eff, + flow_from_commodity_noEff=flow_from_commodity_noEff, + p_unitsize=p_unitsize, + p_flow_upper=p_flow_upper, + p_slope=p_slope, + p_commodity_price=p_commodity_price, + # Storage + nodeState=nodeState, + nodeState_dt=nodeState_dt, + nodeState_first_dt=nodeState_first_dt, + nodeState_last_dt=nodeState_last_dt, + p_state_unitsize=p_state_unitsize, + p_state_upper=p_state_upper, + p_state_self_discharge=p_state_self_discharge, + p_state_existing_capacity=p_state_existing_capacity, + dtttdt=dtttdt, + # Reserve + reserve_upDown_group=reserve_upDown_group, + reserve_upDown_group_method_timeseries=reserve_upDown_group_method_timeseries, + reserve_upDown_group_method_dynamic=reserve_upDown_group_method_dynamic, + reserve_upDown_group_method_n_1=reserve_upDown_group_method_n_1, + prundt=prundt, + process_reserve_upDown_node_active=process_reserve_upDown_node_active, + group_node=group_node, + p_process_reserve_upDown_node_reliability=p_process_reserve_upDown_node_reliability, + pdtReserve_upDown_group_reservation=pdtReserve_upDown_group_reservation, + p_reserve_upDown_group_penalty_reserve=p_reserve_upDown_group_penalty_reserve, + p_process_reserve_upDown_node_max_share=p_process_reserve_upDown_node_max_share, + p_process_existing_count=p_process_existing_count, + ) + if duration is not None: + kwargs["p_reserve_upDown_group_reserve_duration"] = Param( + ("r", "ud", "g"), + pl.DataFrame({"r": ["r1"], "ud": [ud], "g": ["g"], + "value": [float(duration)]})) + return FlexData(**kwargs) diff --git a/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py b/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py new file mode 100644 index 000000000..c1fd60b92 --- /dev/null +++ b/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py @@ -0,0 +1,89 @@ +"""End-to-end DB-reader coverage for ``reserve_duration`` (#322). + +Builds a fresh SQLite from the JSON fixture (``json_to_db`` → tmp DB; +invariant #3 — never a checked-in ``.sqlite``), writes a +``reserve_duration`` value onto the ``(primary, up, electricity)`` reserve +group under the existing ``reserve`` alternative, then reads it back +through the REAL :class:`SpineDbReader` cascade. This exercises the +SpineDB ``parameter_explicit`` path that the in-memory loader unit tests +(``test_direct_params_delta4.py``) cannot. + +Note: the ``network_coal_wind_reserve`` scenario's reserve providers are +generators (coal / wind), NOT storage-backed, so the new coupling +constraints are (correctly) inert there — the storage energy-adequacy +constraints and their solved values are proven in the synthetic engine +suite ``tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py``. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from flextool.engine_polars._spinedb_reader import SpineDbReader +from flextool.engine_polars import _direct_params as dp +from polar_high import Param + +TEST_DIR = Path(__file__).resolve().parents[2] +FIXTURES_DIR = TEST_DIR / "fixtures" + + +@pytest.mark.skipif( + __import__("sys").platform == "win32", + reason="sqlite handle can't be unlinked while open on Windows", +) +def test_reserve_duration_reads_from_real_spinedb(tmp_path: Path): + from db_utils import json_to_db + from spinedb_api import DatabaseMapping, import_data + + db_path = tmp_path / "reserve_duration.sqlite" + url = json_to_db(FIXTURES_DIR / "tests.json", db_path) + + # Append the reserve_duration value under the pre-existing ``reserve`` + # alternative (already ranked into the network_coal_wind_reserve + # scenario). Append-only: no existing value is touched. + with DatabaseMapping(url) as dbm: + count, errors = import_data( + dbm, + # tests.json was exported at v70 (before this param); add the + # definition so the value import resolves. The schema itself + # already declares it — the version bump + fixture re-migration + # (which propagates the definition into tests.json) is the next + # agent's job. + parameter_definitions=[[ + "reserve__upDown__group", "reserve_duration"]], + parameter_values=[[ + "reserve__upDown__group", + ["primary", "up", "electricity"], + "reserve_duration", + 0.75, + "reserve", + ]], + ) + assert not errors, errors + dbm.commit_session("add reserve_duration (#322 e2e)") + + reader = SpineDbReader(url, "network_coal_wind_reserve") + p = dp.p_reserve_upDown_group_reserve_duration_from_source(reader) + assert isinstance(p, Param) + assert p.dims == ("r", "ud", "g") + rows = p.frame.to_dicts() + match = [r for r in rows + if r["r"] == "primary" and r["ud"] == "up" and r["g"] == "electricity"] + assert len(match) == 1, rows + assert match[0]["value"] == pytest.approx(0.75) + + +@pytest.mark.skipif( + __import__("sys").platform == "win32", + reason="sqlite handle can't be unlinked while open on Windows", +) +def test_reserve_duration_absent_from_real_spinedb(tmp_path: Path): + """Without any reserve_duration value the reader yields None (byte- + identical LP — no storage coupling).""" + from db_utils import json_to_db + + db_path = tmp_path / "no_reserve_duration.sqlite" + url = json_to_db(FIXTURES_DIR / "tests.json", db_path) + reader = SpineDbReader(url, "network_coal_wind_reserve") + assert dp.p_reserve_upDown_group_reserve_duration_from_source(reader) is None diff --git a/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py b/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py new file mode 100644 index 000000000..c2952e531 --- /dev/null +++ b/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py @@ -0,0 +1,166 @@ +"""Tier-9 synthetic driver: storage-backed reserve energy adequacy +(``reserve_duration``, issue #322). + +Exercises ``_reserve._add_storage_reserve_constraints`` end to end via +``build_flextool``. Every case runs under ``FLEXTOOL_AUTOSCALE_STRICT=1`` +and additionally re-solves through ``apply_layer2`` so the two new +constraint families are proven complete in the autoscale registry +(CLAUDE.md invariant #1) — a missing entry makes ``apply_layer2`` raise. + +See ``tests/engine_polars/fixtures/flex_toy_reserve_storage.py`` for the +construction and closed-form derivations. +""" +from __future__ import annotations + +import pytest + +from polar_high import Problem +from flextool.engine_polars import build_flextool +from flextool.engine_polars.autoscale import ( + apply_layer2, ScalingConfig, unscale_solution, +) + +from flex_toy_reserve_storage import data + +_OPTS = {"random_seed": 42, "parallel": "off"} +_UP = "reserve_storage_up_floor" +_DN = "reserve_storage_down_headroom" + + +@pytest.fixture(autouse=True) +def _strict_autoscale(monkeypatch): + """Make missing autoscale registry entries loud for every case.""" + monkeypatch.setenv("FLEXTOOL_AUTOSCALE_STRICT", "1") + + +def _solve(d): + pb = Problem() + build_flextool(pb, d) + sol = pb.solve(options=_OPTS) + assert sol.optimal + return pb, sol + + +def _solve_scaled(d): + """Build, apply Layer-2 autoscale (STRICT — raises on an unregistered + family), solve, unscale. Returns the (bit-exact) unscaled solution.""" + pb = Problem() + build_flextool(pb, d) + plan = apply_layer2(pb, ScalingConfig()) + sol = pb.solve(options=_OPTS) + unscale_solution(sol, plan) + assert sol.optimal + return pb, sol + + +def _vals(sol, name): + return sol.value(name)["value"].to_list() + + +# ── Case 1: UP floor binds; raising duration tightens reserve ──────────── + +def test_up_floor_binds_and_tightens_with_duration(): + # duration = 0.5 h: energy floor caps v_reserve at cap/(unitsize·dur) + # = 20 / (100·0.5) = 0.4; reserveBalance ⇒ vq = (50-40)/50 = 0.2. + pb, sol = _solve(data(direction="up", duration=0.5)) + assert _UP in pb.cstr_names() + assert _DN not in pb.cstr_names() + vr_half = _vals(sol, "v_reserve") + vq_half = _vals(sol, "vq_reserve") + assert vr_half == pytest.approx([0.4, 0.4]) + assert vq_half == pytest.approx([0.2, 0.2]) + + # duration = 1.0 h halves the energy allowance: v_reserve → 0.2, + # vq → 0.6. Reserve tightens, slack (and objective) rise. + pb2, sol2 = _solve(data(direction="up", duration=1.0)) + vr_full = _vals(sol2, "v_reserve") + vq_full = _vals(sol2, "vq_reserve") + assert vr_full == pytest.approx([0.2, 0.2]) + assert vq_full == pytest.approx([0.6, 0.6]) + assert vr_full[0] < vr_half[0] # reserve tightened + assert vq_full[0] > vq_half[0] # slack rose + assert sol2.obj > sol.obj # objective rose + + +def test_up_floor_autoscale_strict_roundtrip(): + """Layer-2 (STRICT) must not raise on the new ENERGY family, and the + unscaled objective is bit-exact.""" + _, raw = _solve(data(direction="up", duration=0.5)) + _, scaled = _solve_scaled(data(direction="up", duration=0.5)) + assert scaled.obj == pytest.approx(raw.obj, rel=1e-9) + + +# ── Case 2: DOWN headroom + efficiency (÷slope ⇒ ×η) ───────────────────── + +def test_down_headroom_binds_with_efficiency(): + # eta = 0.9 ⇒ E_dn = v_reserve·100·dur·(1/slope) = v_reserve·100·0.5·0.9. + # LP drives v_state → 0 (max headroom): committed down MW ≤ + # cap·unitsize / (0.5·0.9) = 20 / 0.45 = 44.44 ⇒ v_reserve = 0.4444, + # vq = (50-44.44)/50 = 0.1111. + pb, sol = _solve(data(direction="down", eta=0.9, duration=0.5)) + assert _DN in pb.cstr_names() + assert _UP not in pb.cstr_names() + assert _vals(sol, "v_reserve") == pytest.approx([0.4 / 0.9, 0.4 / 0.9]) + assert _vals(sol, "vq_reserve") == pytest.approx([1.0 / 9.0, 1.0 / 9.0]) + # Autoscale STRICT roundtrip (down family registered). + _, scaled = _solve_scaled(data(direction="down", eta=0.9, duration=0.5)) + assert scaled.obj == pytest.approx(sol.obj, rel=1e-9) + + +def test_down_headroom_tighter_efficiency_costs_more(): + """Lower efficiency stores less per charge-MW ⇒ same headroom backs + LESS reserve is FALSE here; instead ×η means lower η ⇒ smaller E_dn ⇒ + MORE reserve fits. Verify the monotonic direction against η=1.""" + _, lossless = _solve(data(direction="down", eta=1.0, duration=0.5)) + _, lossy = _solve(data(direction="down", eta=0.9, duration=0.5)) + # eta=1: v_reserve ≤ 20/(100·0.5·1) = 0.4. eta=0.9: ≤ 0.4/0.9 ≈ 0.444. + assert _vals(lossy, "v_reserve")[0] > _vals(lossless, "v_reserve")[0] + + +# ── Case 3: connection provider (pss / pruna are class-agnostic) ───────── + +def test_connection_provider_up_floor_binds(): + """The engine keys purely on the process id — a connection-backed + provider couples to storage identically to a unit-backed one.""" + pb, sol = _solve(data(direction="up", duration=0.5, provider="conn_bat_elec")) + assert _UP in pb.cstr_names() + assert _vals(sol, "v_reserve") == pytest.approx([0.4, 0.4]) + assert _vals(sol, "vq_reserve") == pytest.approx([0.2, 0.2]) + + +# ── Case 4: no-op guard — absent reserve_duration ⇒ no coupling ────────── + +def test_no_op_without_duration(): + """Omitting reserve_duration emits NEITHER coupling constraint and + leaves the LP/objective at the pre-feature value (§9 no-op proof).""" + pb, sol = _solve(data(direction="up", duration=None)) + names = pb.cstr_names() + assert _UP not in names + assert _DN not in names + # With no energy floor, reserve is limited only by the maxFlow coupling + # (≤ 1.0) and fully covers the 50 MW reservation ⇒ vq = 0, obj = 0. + assert sol.obj == pytest.approx(0.0, abs=1e-9) + assert _vals(sol, "vq_reserve") == pytest.approx([0.0, 0.0]) + # Byte-identical proof: the feature-on build differs ONLY by the added + # up-floor row; strip the duration and that row (and its objective + # impact) is gone. + pb_on, _ = _solve(data(direction="up", duration=0.5)) + assert set(pb.cstr_names()) | {_UP} == set(pb_on.cstr_names()) + + +# ── Case 5: feasibility guard — a too-short battery stays feasible ─────── + +def test_feasibility_with_long_duration(): + """A multi-hour duration on a small battery must NOT make the model + infeasible: reserve is simply reduced and vq_reserve absorbs the + shortfall at the existing penalty (§3 — no new slack variable).""" + # duration = 10 h ⇒ v_reserve ≤ 20/(100·10) = 0.02; vq = (50-2)/50 = 0.96. + pb, sol = _solve(data(direction="up", duration=10.0)) + assert sol.optimal + assert _vals(sol, "v_reserve") == pytest.approx([0.02, 0.02]) + vq = _vals(sol, "vq_reserve") + assert vq == pytest.approx([0.96, 0.96]) + assert all(v < 1.0 for v in vq) # slack never saturates its cap + # Autoscale STRICT stays clean under the extreme coefficient range. + _, scaled = _solve_scaled(data(direction="up", duration=10.0)) + assert scaled.obj == pytest.approx(sol.obj, rel=1e-9) From 5b65cc4d84d593cfad30d3b62d1687e0a7a0fb02 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 23 Sep 2026 17:21:51 +0300 Subject: [PATCH 58/87] test(reserve): cover invest/divest down-headroom + fix e2e comment (#322) Adds invest/divest-eligible storage coverage to the reserve_duration synthetic suite so the DOWN-headroom capacity-replication block in _reserve.py (the __at_reserve_down_{invest,divest} virtual-var views and the cross-Enum n->e / n->p casts) is exercised through canonicalise() and Layer-2 autoscale (STRICT). Previously no synthetic fixture built an invest/divest storage node, so v_invest_n / v_divest_n were never present when the DOWN-headroom constraint was emitted. - fixture: add invest/divest flags (edd_invest_set / edd_divest_active, p_entity_max_units, annuity cost) on the storage node bat. - new tests: closed-form proof that the +v_invest.unitsize ceiling term is real (invest reaches 5, vq 0.2 -> 0, v_reserve 0.4 -> 0.5), plus an invest+divest build+solve+Layer-2 STRICT roundtrip that canonicalises both capacity views without raising. - fix stale e2e comment: tests.json now ships at v71 with the reserve_duration definition (commit 30286318); the manual import_data is a harmless idempotent upsert. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 3e3fa37a6ebd294bf36a80a33d0ed34e9d0bb98a) --- .../fixtures/flex_toy_reserve_storage.py | 58 ++++++++++++++- .../loaders/test_reserve_duration_db_e2e.py | 10 +-- .../test_flex_toy_reserve_storage.py | 71 +++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/tests/engine_polars/fixtures/flex_toy_reserve_storage.py b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py index 29fbadac8..8cd1cf3a1 100644 --- a/tests/engine_polars/fixtures/flex_toy_reserve_storage.py +++ b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py @@ -57,7 +57,9 @@ def data(*, direction: str = "up", duration: float | None = 0.5, eta: float = 1.0, cap: float = 20.0, - provider: str = "prov") -> FlexData: + provider: str = "prov", invest: bool = False, + divest: bool = False, invest_cost: float = 10.0, + max_units: float = 100.0) -> FlexData: """Build the storage-backed reserve toy. Parameters @@ -74,6 +76,25 @@ def data(*, direction: str = "up", duration: float | None = 0.5, cap : float Storage ``p_state_upper`` in var-units (max stored energy = cap · p_state_unitsize MWh). + invest : bool + Make the storage node ``bat`` invest-eligible (adds ``v_invest_n``, + ``edd_invest_set`` and an annuity cost ``invest_cost`` per invested + var-unit). Exercises the DOWN-headroom ceiling's + ``+ v_invest_n·unitsize`` capacity-relaxation term (and its + ``__at_reserve_down_invest`` view + n→e Enum cast through + ``canonicalise()``). Only meaningful in the DOWN direction. + divest : bool + Make ``bat`` divest-eligible (adds ``v_divest_n`` + + ``edd_divest_active``). No divest credit is authored, so the LP + leaves ``v_divest_n = 0`` — but the DOWN-headroom's + ``+ v_divest_n·unitsize`` term (``__at_reserve_down_divest`` view + + n→p Enum cast) is still *built and canonicalised*, closing the + runtime-coverage gap on the divest branch (structurally identical + to invest, mirrored sign). + invest_cost : float + Annuity (``ed_entity_annual_discounted``) per invested var-unit. + max_units : float + ``p_entity_max_units`` structural cap on invest/divest. """ ud = "up" if direction == "up" else "down" slope = 1.0 / eta @@ -239,4 +260,39 @@ def data(*, direction: str = "up", duration: float | None = 0.5, ("r", "ud", "g"), pl.DataFrame({"r": ["r1"], "ud": [ud], "g": ["g"], "value": [float(duration)]})) + + # ── Invest / divest eligibility on the storage node ``bat`` ─────────── + # Mirrors the storage-invest setup in + # ``tests/engine_polars/constraints/test_b01_b02_balance_storage.py`` + # (``test_profile_state_upper_invest_tightening``). Exercises the + # DOWN-headroom capacity-replication block in ``_reserve.py`` — the + # ``__at_reserve_down_{invest,divest}`` virtual-var views and the cross- + # Enum n→e / n→p casts that ride through ``canonicalise()``. + if invest or divest: + # ``p_entity_max_units`` is the shared invest/divest structural cap + # (required by the ``INVEST`` field check and the maxInvest/maxDivest + # var bounds). + kwargs["p_entity_max_units"] = Param(("e", "d"), + pl.DataFrame({"e": ["bat"], "d": ["p2020"], + "value": [float(max_units)]})) + if invest: + kwargs["nd_invest_set"] = pl.DataFrame({"n": ["bat"], "d": ["p2020"]}) + kwargs["ed_invest_set"] = pl.DataFrame({"e": ["bat"], "d": ["p2020"]}) + kwargs["edd_invest_set"] = pl.DataFrame( + {"e": ["bat"], "d_invest": ["p2020"], "d": ["p2020"]}) + # Annuity per invested var-unit — cheap enough that relaxing the + # DOWN-headroom ceiling by investing beats paying the reserve + # shortfall penalty. + kwargs["ed_entity_annual_discounted"] = Param(("e", "d"), + pl.DataFrame({"e": ["bat"], "d": ["p2020"], + "value": [float(invest_cost)]})) + if divest: + kwargs["nd_divest_set"] = pl.DataFrame({"n": ["bat"], "d": ["p2020"]}) + # edd_divest_active carries the entity on the ``p`` axis (shared + # process/node entity vocab); the DOWN-headroom block casts the + # storage node n→p to semi-join it. No divest credit is authored, + # so ``v_divest_n`` stays 0 at the optimum — the term is still built. + kwargs["edd_divest_active"] = pl.DataFrame( + {"p": ["bat"], "d_divest": ["p2020"], "d": ["p2020"]}) + return FlexData(**kwargs) diff --git a/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py b/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py index c1fd60b92..699295e79 100644 --- a/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py +++ b/tests/engine_polars/loaders/test_reserve_duration_db_e2e.py @@ -45,11 +45,11 @@ def test_reserve_duration_reads_from_real_spinedb(tmp_path: Path): with DatabaseMapping(url) as dbm: count, errors = import_data( dbm, - # tests.json was exported at v70 (before this param); add the - # definition so the value import resolves. The schema itself - # already declares it — the version bump + fixture re-migration - # (which propagates the definition into tests.json) is the next - # agent's job. + # tests.json now ships at v71 WITH the reserve_duration + # parameter_definition (regenerated in commit 30286318), so this + # re-declaration is a harmless idempotent upsert — kept only so + # the value import still resolves if the fixture is ever rebuilt + # from an older export. parameter_definitions=[[ "reserve__upDown__group", "reserve_duration"]], parameter_values=[[ diff --git a/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py b/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py index c2952e531..bf2a6c015 100644 --- a/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py +++ b/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py @@ -164,3 +164,74 @@ def test_feasibility_with_long_duration(): # Autoscale STRICT stays clean under the extreme coefficient range. _, scaled = _solve_scaled(data(direction="up", duration=10.0)) assert scaled.obj == pytest.approx(sol.obj, rel=1e-9) + + +# ── Case 6: invest/divest DOWN-headroom capacity replication ───────────── +# These exercise the invest/divest branch of the DOWN-headroom block in +# ``_reserve._add_storage_reserve_constraints`` — the +# ``__at_reserve_down_{invest,divest}`` virtual-var views and the cross-Enum +# n→e / n→p casts that ride through ``canonicalise()``. Before this case no +# synthetic fixture built an invest/divest storage node, so ``v_invest_n`` / +# ``v_divest_n`` were never present when the DOWN-headroom constraint was +# emitted — that whole (canonicalise-crash-risk) code path was untested. + +def test_down_headroom_invest_relaxes_ceiling(): + """The ``+ v_invest_n·unitsize`` term on the DOWN-headroom ceiling is + real: with investment allowed the storage node grows just enough + capacity to commit MORE down-reserve (and pay LESS shortfall penalty) + than the same model with capacity fixed at the existing level. + + Closed form (down, eta=1, dur=0.5, cap=20, unitsize_bat=1, + provider unitsize=100, reservation=50, penalty=1000): + + * Fixed capacity (no invest set): + E_dn = v_reserve·100·0.5 ≤ cap·1 = 20 ⇒ v_reserve ≤ 0.4; + reserveBalance ⇒ vq = (50−40)/50 = 0.2, obj = 0.2·50·1000·2 = 20000. + * Invest allowed (annuity 10 / var-unit): + ceiling relaxes to (20 + v_invest_n)·1. Fully covering the 50 MW + reservation needs v_reserve = 0.5 ⇒ E_dn = 25 ⇒ v_invest_n ≥ 5. + Cost 5·10 = 50 ≪ 20000 penalty ⇒ the LP invests 5, vq → 0. + """ + base_pb, base = _solve(data(direction="down", eta=1.0, duration=0.5)) + inv_pb, inv = _solve( + data(direction="down", eta=1.0, duration=0.5, invest=True)) + assert _DN in inv_pb.cstr_names() + + # Fixed-capacity baseline: headroom-bound at 0.4, 0.2 shortfall. + assert _vals(base, "v_reserve") == pytest.approx([0.4, 0.4]) + assert _vals(base, "vq_reserve") == pytest.approx([0.2, 0.2]) + + # Invest term relaxes the ceiling — non-tautological: strictly more + # down-reserve is committed and the shortfall strictly drops. + v_inv = _vals(inv, "v_invest_n") + assert v_inv[0] > 0.0 # investment happens + assert v_inv[0] == pytest.approx(5.0) # closed form + assert _vals(inv, "v_reserve") == pytest.approx([0.5, 0.5]) + assert _vals(inv, "vq_reserve") == pytest.approx([0.0, 0.0]) + assert _vals(inv, "v_reserve")[0] > _vals(base, "v_reserve")[0] + assert _vals(inv, "vq_reserve")[0] < _vals(base, "vq_reserve")[0] + + +def test_down_headroom_invest_divest_autoscale_strict_roundtrip(): + """Build + solve + Layer-2 autoscale roundtrip with BOTH an invest- and + a divest-eligible storage node must NOT raise: this drives the + ``__at_reserve_down_invest`` (n→e) AND ``__at_reserve_down_divest`` (n→p) + virtual-var views and their cross-Enum casts through ``canonicalise()`` + — the core value of this case. The unscaled objective is bit-exact. + + No divest credit is authored, so the LP leaves ``v_divest_n = 0`` (the + term is built and canonicalised regardless); investment still relaxes + the ceiling exactly as in the invest-only case. + """ + d_kw = dict(direction="down", eta=1.0, duration=0.5, + invest=True, divest=True) + raw_pb, raw = _solve(data(**d_kw)) + assert _DN in raw_pb.cstr_names() + # Both capacity vars are present in the built LP (``sol.value`` would + # raise otherwise): invest realized (5), divest untouched (0) — the + # divest term is still built and canonicalised regardless. + assert _vals(raw, "v_invest_n") == pytest.approx([5.0]) + assert _vals(raw, "v_divest_n") == pytest.approx([0.0]) + + _, scaled = _solve_scaled(data(**d_kw)) + assert scaled.obj == pytest.approx(raw.obj, rel=1e-9) From 6c2a24ffde7556d03f9d7583aae01588fab43f36 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Thu, 1 Oct 2026 23:06:17 +0300 Subject: [PATCH 59/87] =?UTF-8?q?feat(schema):=20v74=20folded=20bump=20?= =?UTF-8?q?=E2=80=94=20rename=20NA=20knobs=20to=20shared=5F*=20+=20reserve?= =?UTF-8?q?=5Fduration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the reserve line onto the stochastics-finish line (construction- lead-time, v73) and resolve the duplicate-v71 collision in a single schema bump v73 -> v74: - Rename the two unreleased stochastic non-anticipativity window knobs to positive names (user-facing DB params only; internal non-anticipativity mechanism names kept, since they genuinely implement non-anticipativity): non_anticipativity_periods -> shared_operation_periods non_anticipativity_invest_periods -> shared_invest_periods - Add reserve_duration (reserve__upDown__group, issue #322) to existing DBs via the v74 migration (reader/constraints cherry-picked from the reserve line; the v71 reserve migration was dropped to avoid the collision and re-authored at v74). - _migrate_v74 does both (collision-safe rename helper + reserve_duration add, idempotent); FLEXTOOL_DB_VERSION 73 -> 74; fixtures/canonical DBs regen via the standard chain; new names registered in PARAMETER_TYPES (autoscale). Verified (FLEXTOOL_AUTOSCALE_STRICT=1): migration v71-v74 23 passed; reserve 198 passed; stochastics/NA 31 passed (invest-NA tie binds under shared_invest_periods, RP 173250); autoscale coverage 39 passed; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- CHANGELOG.md | 12 +- docs/how_to.md | 16 +- flextool/engine_polars/_solve_config.py | 8 +- .../autoscale/_quantity_types.py | 8 +- flextool/engine_polars/model.py | 2 +- .../howto_aggregate_output.json | 42 ++-- .../howto_connections.json | 42 ++-- .../canonical_databases/howto_demand.json | 42 ++-- .../howto_hydro_reservoir.json | 42 ++-- .../howto_hydro_reservoir_with_pump.json | 42 ++-- .../howto_non_sync_and_curtailment.json | 42 ++-- .../howto_ramp_and_start_up.json | 42 ++-- .../howto_stochastics.json | 42 ++-- .../templates_examples.json | 42 ++-- .../templates_time_settings_only.json | 42 ++-- flextool/schemas/spinedb_schema.json | 34 +-- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 111 +++++++++ tests/conftest.py | 2 +- .../test_configurable_nonanticipativity.py | 6 +- .../test_construction_lead_time_staging.py | 4 +- tests/engine_polars/test_invest_na_window.py | 8 +- tests/fixtures/branch2_parent_period.json | 57 +++-- .../build_stoch_two_period_free_ops.py | 27 ++- .../fixtures/build_stoch_two_period_hedge.py | 6 +- tests/fixtures/case14_dc_power_flow.json | 57 +++-- tests/fixtures/h2_trade_parity.json | 57 +++-- tests/fixtures/lh2_three_region.json | 57 +++-- tests/fixtures/stoch_two_period_free_ops.json | 202 ++++++++++++++-- tests/fixtures/stoch_two_period_hedge.json | 211 ++++++++++++++-- tests/fixtures/stochastics.json | 57 +++-- tests/fixtures/stochastics_pbt_inflow.json | 57 +++-- tests/fixtures/tests.json | 57 +++-- .../test_v71_non_anticipativity_periods.py | 18 +- ...t_v72_non_anticipativity_invest_periods.py | 18 +- .../test_v74_rename_and_reserve_duration.py | 228 ++++++++++++++++++ 36 files changed, 1290 insertions(+), 452 deletions(-) create mode 100644 tests/update_flextool/test_v74_rename_and_reserve_duration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bce52e39..072f4ada8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ ## Unreleased Schema change (adds `solve.stochastic_invest_method`, -`solve.non_anticipativity_invest_periods`, and per-entity +`solve.shared_invest_periods`, `solve.shared_operation_periods`, +`reserve__upDown__group.reserve_duration`, and per-entity `construction_lead_time` / `construction_lead_time_method` on -`unit`/`connection`/`node`; migrated automatically). +`unit`/`connection`/`node`; migrated automatically). The stochastic +shared-decision window parameters use positive names: +`shared_invest_periods` / `shared_operation_periods` name the periods +whose decisions are *shared* (identical) across stochastic branches. - **Construction lead time.** New per-entity parameters `construction_lead_time` ([years]) and `construction_lead_time_method` @@ -21,11 +25,11 @@ Schema change (adds `solve.stochastic_invest_method`, `immediate`) every existing result is byte-identical. - **Standard two-stage stochastic investment (the main mode).** The new - `solve.non_anticipativity_invest_periods` parameter names the periods over + `solve.shared_invest_periods` parameter names the periods over which investment is a single shared *here-and-now* decision: `v_invest` and `v_divest` are tied across the stochastic branches over those periods, while later periods invest per branch (recourse). Compose it with - `stochastic_invest_method = recourse` and `non_anticipativity_periods = []` + `stochastic_invest_method = recourse` and `shared_operation_periods = []` to get the classic capacity-expansion structure — shared first-period investment, per-scenario recourse investment thereafter, operations free from the first timestep. Both invest and divest are tied so the *net* first-stage diff --git a/docs/how_to.md b/docs/how_to.md index 1d7f9fcef..d8bb17408 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -284,7 +284,7 @@ Additional parameters: - `previous_seam` — round the commissioning year *down* to the preceding boundary. - `next_seam` — round *up* to the following boundary. If no in-horizon boundary is on/after the commissioning year the order buys no capacity within the model horizon (a warning is issued); a *forced* such order (`invest_forced` / `invest_min_period`) is rejected with a clear error rather than charged for never-delivered capacity. - **Construction lead time and stochastics compose automatically.** Under recourse (`stochastic_invest_method = recourse`) with an invest non-anticipativity window (`non_anticipativity_invest_periods`, see the stochastics how-to), a long-lead technology that must be online *after* the branch reveal has to be *ordered* before it — in a pre-reveal, tied period — so its investment is a single shared here-and-now decision, while a short-lead technology can be ordered at/after the reveal and react per scenario. This staging is emergent (no extra parameter): set the window to the pre-reveal periods and the lead time stages the technologies for you. Note the two knobs stay orthogonal — the window is the *information* boundary (which order periods are shared), the lead time is the *physics* (how early you must order); you need both, set consistently, for staging. + **Construction lead time and stochastics compose automatically.** Under recourse (`stochastic_invest_method = recourse`) with an invest non-anticipativity window (`shared_invest_periods`, see the stochastics how-to), a long-lead technology that must be online *after* the branch reveal has to be *ordered* before it — in a pre-reveal, tied period — so its investment is a single shared here-and-now decision, while a short-lead technology can be ordered at/after the reveal and react per scenario. This staging is emergent (no extra parameter): set the window to the pre-reveal periods and the lead time stages the technologies for you. Note the two knobs stay orthogonal — the window is the *information* boundary (which order periods are shared), the lead time is the *physics* (how early you must order); you need both, set consistently, for staging. - `salvage_value`: Sets the extra value that can be gained for retiring [CUR/kW] - `fixed_cost`: Annual cost for capacity [CUR/kW] @@ -1174,7 +1174,7 @@ So a plain **dispatch** solve = commit the realized part (shared storage / onlin **Overriding the defaults:** - *Investment* — set `stochastic_invest_method = recourse` for per-scenario (wait-and-see) investment, or declare a mid-horizon reveal for a genuine two-stage hedge (see *Stochastic investment* below). -- *Operations* — set the `solve` array parameter `non_anticipativity_periods` to change the operational tie window: an **empty array** frees operations from the first timestep (each scenario operates freely — combined with a single shared investment this is the classic two-stage capacity-expansion model), or a **list of periods** ties operations only over those periods. See *Stochastics over multiple periods* for the details. +- *Operations* — set the `solve` array parameter `shared_operation_periods` to change the operational tie window: an **empty array** frees operations from the first timestep (each scenario operates freely — combined with a single shared investment this is the classic two-stage capacity-expansion model), or a **list of periods** ties operations only over those periods. See *Stochastics over multiple periods* for the details. ### Single solve stochastics **(stochastics.sqlite scenario: 2_day_stochastic_dispatch)** @@ -1204,7 +1204,7 @@ Notes for multi-period stochastic solves: - If `years_represented` is defined for the solve, it must cover **all** of the solve's periods. A configuration that defines it for only some periods fails with a configuration error (older versions silently ran on a truncated horizon instead). - The realized branch's input weight is always treated as 1.0 along the whole realized chain — the sibling branches' weights are normalized against 1.0, not against a DB value on the realized row. - In a single solve whose `realized_periods` span the whole horizon, the non-anticipatory constraints cover the whole horizon: the shared decision types (storage usage, online status, reserve) stay pinned to the realized branch through the later periods, and post-branching divergence shows up only in the flow variables. Use a rolling solve when the branches should be free to commit differently after the information is revealed — each roll then realizes only its jump slice and the non-anticipatory window ends at the reveal. -- To free the operational branches within a *single* solve (without moving to a rolling solve), set the `solve` array parameter `non_anticipativity_periods`. Left unset (the default) it keeps the behaviour above — the operational non-anticipativity window equals the realized-dispatch timesteps (plus any `fix_storage` timesteps), so operations stay pinned across the whole realized horizon. Set it to an **empty array** to free operations from the first timestep: the shared decision types then branch freely per scenario while the investment plan is still a single shared decision (`stochastic_invest_method = none`) — this is the classic two-stage capacity-expansion structure (one here-and-now investment, wait-and-see operations). Set it to a **list of periods** to pin operations only over those periods (e.g. a mid-horizon reveal: pin the pre-reveal periods, free the rest); the listed periods' timesteps are intersected with the solve's active timeline, and a curated list *replaces* (does not add to) the default realized-dispatch window. The dispatch output window is unaffected — `realized_periods` still governs which periods are reported. +- To free the operational branches within a *single* solve (without moving to a rolling solve), set the `solve` array parameter `shared_operation_periods`. Left unset (the default) it keeps the behaviour above — the operational non-anticipativity window equals the realized-dispatch timesteps (plus any `fix_storage` timesteps), so operations stay pinned across the whole realized horizon. Set it to an **empty array** to free operations from the first timestep: the shared decision types then branch freely per scenario while the investment plan is still a single shared decision (`stochastic_invest_method = none`) — this is the classic two-stage capacity-expansion structure (one here-and-now investment, wait-and-see operations). Set it to a **list of periods** to pin operations only over those periods (e.g. a mid-horizon reveal: pin the pre-reveal periods, free the rest); the listed periods' timesteps are intersected with the solve's active timeline, and a curated list *replaces* (does not add to) the default realized-dispatch window. The dispatch output window is unaffected — `realized_periods` still governs which periods are reported. - A `realized: yes` row placed at a later period's first timestep does not re-branch the model, but it selects which forecast branch's timeseries the realized chain uses from that period onwards. This can be used deliberately for per-period forecast-series selection; if you did not intend a series swap, keep the `realized: yes` rows only at the solve (or roll) start. ### Rolling horizon stochastics @@ -1233,15 +1233,15 @@ By default (`solve.stochastic_invest_method = none`) a stochastic model makes a The usual goal is a genuine two-stage plan: **one shared here-and-now investment decision, made before the uncertainty resolves, with every scenario then operating that fleet freely from the first timestep** according to its own realization. The investment is the first-stage (non-anticipative) decision; *all operations are second-stage recourse* — nothing operational is tied across the branches. -For a model that invests in a single period this is available today: keep `stochastic_invest_method = none` (one shared investment) and set `non_anticipativity_periods = []` (operations branch freely from t0 — see *What the model ties across branches* above). The result is the classic stochastic capacity-expansion model — commit one fleet, minimise the probability-weighted cost of operating it across the scenarios. +For a model that invests in a single period this is available today: keep `stochastic_invest_method = none` (one shared investment) and set `shared_operation_periods = []` (operations branch freely from t0 — see *What the model ties across branches* above). The result is the classic stochastic capacity-expansion model — commit one fleet, minimise the probability-weighted cost of operating it across the scenarios. -For a **multi-period** investment horizon the ideal is that only the *first* period's investment is shared (the genuine here-and-now decision) while later-period investment adapts per scenario (recourse), since by then some uncertainty has resolved. This is delivered by the **investment non-anticipativity window** — the `solve` array parameter `non_anticipativity_invest_periods`. Compose three knobs: +For a **multi-period** investment horizon the ideal is that only the *first* period's investment is shared (the genuine here-and-now decision) while later-period investment adapts per scenario (recourse), since by then some uncertainty has resolved. This is delivered by the **investment non-anticipativity window** — the `solve` array parameter `shared_invest_periods`. Compose three knobs: - `stochastic_invest_method = recourse` (so per-branch investment variables exist to tie); -- `non_anticipativity_invest_periods = []` (tie the first period's `v_invest`/`v_divest` across branches — a single shared here-and-now decision — while later periods invest per-branch); -- `non_anticipativity_periods = []` (operations branch freely from t0). +- `shared_invest_periods = []` (tie the first period's `v_invest`/`v_divest` across branches — a single shared here-and-now decision — while later periods invest per-branch); +- `shared_operation_periods = []` (operations branch freely from t0). -The result is the standard two-stage stochastic capacity-expansion structure: **shared first-period investment + per-branch (recourse) investment later + free operations**. Set `non_anticipativity_invest_periods` to more than one period to extend the shared window (e.g. `[p2035, p2040]` shares the first two periods, recourse from the third). Left unset (the default) or set to an empty array it adds no tie — `recourse` stays per-branch everywhere, byte-identical to before. Both invest and divest are tied over the window, so the *net* first-stage capacity (existing + invest − divest) is genuinely shared. The window requires `recourse`; on a `none` solve it is a harmless no-op (investment is already shared across all periods). +The result is the standard two-stage stochastic capacity-expansion structure: **shared first-period investment + per-branch (recourse) investment later + free operations**. Set `shared_invest_periods` to more than one period to extend the shared window (e.g. `[p2035, p2040]` shares the first two periods, recourse from the third). Left unset (the default) or set to an empty array it adds no tie — `recourse` stays per-branch everywhere, byte-identical to before. Both invest and divest are tied over the window, so the *net* first-stage capacity (existing + invest − divest) is genuinely shared. The window requires `recourse`; on a `none` solve it is a harmless no-op (investment is already shared across all periods). #### Per-scenario (wait-and-see) investment diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index be4422b04..908100b15 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -690,7 +690,7 @@ def _opt_int(raw: dict, key: str) -> int | None: # ``duplicate_solve``'s dup_map_list). obj.non_anticipativity_periods = ( obj.non_anticipativity_periods_to_tristate( - db=db, cl="solve", par="non_anticipativity_periods" + db=db, cl="solve", par="shared_operation_periods" ) ) # Invest-NA window (v72) — plain periods_to_tuples: unset == [] == @@ -699,7 +699,7 @@ def _opt_int(raw: dict, key: str) -> int | None: # registered any duplicated solve names; the value for those is # carried by duplicate_solve's dup_map_list. obj.non_anticipativity_invest_periods = obj.periods_to_tuples( - db=db, cl="solve", par="non_anticipativity_invest_periods" + db=db, cl="solve", par="shared_invest_periods" ) return obj @@ -1069,9 +1069,9 @@ def non_anticipativity_periods_to_tristate( self, db: "DatabaseMapping", cl: str = "solve", - par: str = "non_anticipativity_periods", + par: str = "shared_operation_periods", ) -> dict[str, list | None]: - """Read ``non_anticipativity_periods`` as a TRI-STATE dict (Slice G). + """Read ``shared_operation_periods`` as a TRI-STATE dict (Slice G). Unlike :meth:`periods_to_tuples` (which returns a ``defaultdict(list)`` that cannot tell "no parameter row" apart diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 4b5a95939..bad14c992 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -355,10 +355,10 @@ class QuantityType(Enum): # Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other ti... ('node_type', 'node'): QuantityType.DIMENSIONLESS, # Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' ... - ('non_anticipativity_invest_periods', 'solve'): QuantityType.DIMENSIONLESS, - # Array of periods over which investment (v_invest/v_divest) is tied across stochastic branches (the standard two-stage mode). Unset/empty = no tie. - ('non_anticipativity_periods', 'solve'): QuantityType.DIMENSIONLESS, - # Array of periods over which operational non-anticipativity is enforced (Slice G). Unset = legacy realized_dispatch u fix_storage window; empty = operations free from t0. + ('shared_invest_periods', 'solve'): QuantityType.DIMENSIONLESS, + # Array of periods over which investment (v_invest/v_divest) is SHARED across stochastic branches (the standard two-stage mode). Unset/empty = no shared tie. + ('shared_operation_periods', 'solve'): QuantityType.DIMENSIONLESS, + # Array of periods over which operations are SHARED across stochastic branches (Slice G). Unset = legacy realized_dispatch u fix_storage window; empty = operations free from t0. ('non_synchronous_limit', 'group'): QuantityType.FRACTION, # [share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period. ('other_operational_cost', 'connection'): QuantityType.PRICE_PER_ENERGY, diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index d5dceca1a..b5d56c1ba 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -4581,7 +4581,7 @@ def _wt(param): # tie. Graceful no-op + advisory (never mutates the problem, # design §4.5 / OQ-3). _LOG.info( - "non_anticipativity_invest_periods is set but " + "shared_invest_periods is set but " "stochastic_invest_method is not 'recourse'; investment is " "already shared across all periods, so the invest-NA window " "has no effect (use 'recourse' for shared-first-period / " diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index fabdaa367..8e521d0fe 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -1594,7 +1594,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -2085,6 +2085,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -2218,22 +2226,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -2301,6 +2293,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index 2a7c221fc..9448d3d1b 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -1229,7 +1229,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1731,6 +1731,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1864,22 +1872,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1939,6 +1931,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index d618f4c15..38e2cc266 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -1145,7 +1145,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1647,6 +1647,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1780,22 +1788,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1855,6 +1847,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index 45597e689..8a78c157e 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -1398,7 +1398,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1900,6 +1900,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -2033,22 +2041,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -2108,6 +2100,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index 1c1d5c5b0..a59b5b4be 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -1247,7 +1247,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1749,6 +1749,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1882,22 +1890,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1957,6 +1949,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index f1ba6e207..103256eee 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -1276,7 +1276,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1767,6 +1767,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1900,22 +1908,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1975,6 +1967,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index 058f9fb71..99c9bede4 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -1216,7 +1216,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1718,6 +1718,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1851,22 +1859,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1926,6 +1918,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index b07a2aaa8..1cab4e5a4 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -1337,7 +1337,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1831,6 +1831,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1972,22 +1980,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -2047,6 +2039,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index bd189c1cd..40a9c2679 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -2519,7 +2519,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -3010,6 +3010,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3143,22 +3151,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3218,6 +3210,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index 7332f8a37..785c013ea 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -1095,7 +1095,7 @@ "model", "version", [ - "73", + "74", "float" ], null, @@ -1586,6 +1586,14 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + null, + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW × reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -1727,22 +1735,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -1802,6 +1794,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index cabb66e53..2009e8339 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -1643,7 +1643,7 @@ [ "model", "version", - 73.0, + 74.0, null, "Contains database version information.", "model" @@ -2216,22 +2216,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - null, - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches — a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - null, - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -2288,6 +2272,22 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + null, + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + null, + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches — the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index 6e4b39b53..192ee69d0 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 73 +FLEXTOOL_DB_VERSION: int = 74 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index ce6783408..7eb61a846 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1769,6 +1769,8 @@ def _is_yes(pv): _migrate_v72_non_anticipativity_invest_periods(db) elif next_version == 73: _migrate_v73_construction_lead_time(db) + elif next_version == 74: + _migrate_v74(db) else: print("Version invalid") last_completed_version = next_version @@ -4103,6 +4105,115 @@ def _migrate_v73_construction_lead_time(db) -> None: ) +# Positive, non-negated descriptions for the renamed stochastic +# shared-decision window parameters (v74). The old ``non_anticipativity_*`` +# names were negations of the same concept; the new names state it +# positively: these periods are SHARED (identical) across branches. +_SHARED_OPERATION_PERIODS_DESC = ( + "Array of periods over which operations are SHARED (identical) across " + "stochastic branches — the non-anticipative first-stage decision; " + "later periods are per-branch recourse. Unset (default) uses the " + "legacy window = all realized-dispatch timesteps union the fix_storage " + "timesteps (byte-identical to prior behaviour). An explicitly empty " + "Array frees operations from t0 (classic two-stage capacity " + "expansion: one shared here-and-now investment plus operations that " + "branch freely per scenario). A period list shares operations only " + "over those periods' timesteps." +) +_SHARED_INVEST_PERIODS_DESC = ( + "Array of periods over which investment is SHARED (identical) across " + "stochastic branches — the non-anticipative first-stage decision; " + "later periods are per-branch recourse. Unset (default) or an empty " + "Array means NO shared tie (byte-identical to prior behaviour: " + "recourse stays per-branch, none stays shared). A period list -- e.g. " + "the first period -- shares investment over those periods while later " + "periods invest per-branch (recourse), the standard two-stage " + "stochastic capacity-expansion mode. Requires " + "stochastic_invest_method=recourse; a no-op under none (investment is " + "already shared across all periods)." +) + + +def _migrate_v74(db) -> None: + """Rename the stochastic shared-decision windows + add reserve_duration + (v73 -> v74). + + Two folded changes: + + 1. **Rename** the two (unreleased) non-anticipativity window params on + the ``solve`` class to positive, non-negated names: + + * ``non_anticipativity_periods`` -> ``shared_operation_periods`` + * ``non_anticipativity_invest_periods`` -> ``shared_invest_periods`` + + The renames are collision-safe (``_rename_or_drop_parameter_definition``) + and carry their existing per-solve Array values by definition id, so + a migrated DB keeps identical behaviour under the new names. The + descriptions are rewritten to state the meaning positively (these + periods are SHARED/identical across branches — the non-anticipative + first-stage decision). No-ops when the source names are absent (a DB + built fresh above v74 already carries the new names). + + 2. **Add** ``reserve__upDown__group.reserve_duration`` for DBs that + predate the parameter (mirrors ``_migrate_v71_reserve_duration`` from + the original branch). A single scalar (hours) coupling a + storage-backed reserve provider's committed reserve power to the + stored energy (up/discharge) or free headroom (down/charge) needed + to sustain it for ``reserve_duration`` hours (issue #322). Absent or + 0 means no storage-energy coupling, keeping the LP byte-identical to + prior behaviour, so the parameter defaults to null and carries no + value list. Idempotent via ``add_update_item`` — a no-op when the + definition is already present (e.g. the master template, which + already carries it). Attached to the existing ``reserve`` parameter + group when that group is present. + """ + # 1. Rename the two shared-decision window parameters. + _rename_or_drop_parameter_definition( + db, "solve", "non_anticipativity_periods", + "shared_operation_periods", _SHARED_OPERATION_PERIODS_DESC, + ) + _rename_or_drop_parameter_definition( + db, "solve", "non_anticipativity_invest_periods", + "shared_invest_periods", _SHARED_INVEST_PERIODS_DESC, + ) + + # 2. Add reserve__upDown__group.reserve_duration (storage energy + # adequacy for reserves, #322) for DBs that predate it. + db.add_update_item( + "parameter_definition", + entity_class_name="reserve__upDown__group", + name="reserve_duration", + default_value=None, + default_type=None, + parameter_value_list_name=None, + description=( + "[h] Time span over which committed reserve power must be " + "sustainable from storage. Energy obligation = reservation_MW × " + "reserve_duration, corrected by the reserve provider's " + "efficiency. Only affects reserve provided by units/connections " + "that draw on (up) or charge (down) a storage node. Absent or 0 " + "means no storage-energy coupling. Constant." + ), + ) + # Attach to the 'reserve' parameter group when it exists (consistent + # with reservation / reserve_method). + if db.get_item("parameter_group", name="reserve"): + db.add_update_item( + "parameter_definition", + entity_class_name="reserve__upDown__group", + name="reserve_duration", + parameter_group_name="reserve", + ) + + _commit_step(db, + "v74: renamed non_anticipativity_periods -> shared_operation_periods " + "and non_anticipativity_invest_periods -> shared_invest_periods " + "(positive names for the stochastic shared-decision windows); added " + "reserve__upDown__group.reserve_duration ([h] scalar, default none; " + "storage energy-adequacy coupling for reserves, #322)." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). diff --git a/tests/conftest.py b/tests/conftest.py index c88df5054..b3014a403 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -568,7 +568,7 @@ def stoch_two_period_free_ops_db_url( Built from ``tests/fixtures/stoch_two_period_free_ops.json`` (generated by ``tests/fixtures/build_stoch_two_period_free_ops.py``): two scenarios - (``free_ops`` = ``non_anticipativity_periods=[]`` -> operations free + (``free_ops`` = ``shared_operation_periods=[]`` -> operations free from t0; ``pinned_ops`` = unset -> legacy window byte-parity guard) on identical data. Defensive migration as in :func:`stochastic_db_url`. """ diff --git a/tests/engine_polars/test_configurable_nonanticipativity.py b/tests/engine_polars/test_configurable_nonanticipativity.py index b6e0be203..03d5726b3 100644 --- a/tests/engine_polars/test_configurable_nonanticipativity.py +++ b/tests/engine_polars/test_configurable_nonanticipativity.py @@ -3,7 +3,7 @@ Design: ``specs/sliceG_configurable_nonanticipativity_design.md`` (§4 byte-parity, §7 hand-checkable test). -The new ``solve.non_anticipativity_periods`` parameter (v71) decouples the +The new ``solve.shared_operation_periods`` parameter (v71) decouples the operational-NA window (the (d,t) set over which the four ``non_anticipativity_*`` families pin storage / online / reserve dispatch across stochastic branches) from ``realized_periods``. Tri-state: @@ -21,7 +21,7 @@ ``v_invest``), and a stochastic-group storage node ``resv`` fed by branch-asymmetric wind timing so each branch has a UNIQUE cost-optimal storage trajectory (design F6). Two scenarios differ ONLY in the -``non_anticipativity_periods`` value. +``shared_operation_periods`` value. Hand-verified reference numbers (deterministic LP; see the builder): @@ -120,7 +120,7 @@ def _v_state_by_branch(sol, period_prefix: str) -> dict[str, list[float]]: # --------------------------------------------------------------------------- -# free_ops (non_anticipativity_periods = []) — operations free from t0 +# free_ops (shared_operation_periods = []) — operations free from t0 # --------------------------------------------------------------------------- diff --git a/tests/engine_polars/test_construction_lead_time_staging.py b/tests/engine_polars/test_construction_lead_time_staging.py index ace05ac45..18dfe6bb2 100644 --- a/tests/engine_polars/test_construction_lead_time_staging.py +++ b/tests/engine_polars/test_construction_lead_time_staging.py @@ -1,7 +1,7 @@ """Slice H — §7b stochastic staging composition (solver-free). Encoding A keeps ``v_invest`` order-indexed; the invest-NA window -(``non_anticipativity_invest_periods``, v72) ties ``v_invest[e, o]`` across +(``shared_invest_periods``, v72) ties ``v_invest[e, o]`` across branches for order periods ``o`` in the window. Staging is then a CONSEQUENCE of the deterministic lag, needing no new constraint (design §4): to be online at the reveal period a long-lead asset must be *ordered* @@ -34,7 +34,7 @@ PERIODS = ["p1", "p2", "p3", "p4"] YEARS = [0.0, 5.0, 8.0, 20.0] REVEAL = "p3" -WINDOW = {"p1", "p2"} # pre-reveal (non_anticipativity_invest_periods) +WINDOW = {"p1", "p2"} # pre-reveal (shared_invest_periods) class _StubSource: diff --git a/tests/engine_polars/test_invest_na_window.py b/tests/engine_polars/test_invest_na_window.py index 9c39293f4..afc221295 100644 --- a/tests/engine_polars/test_invest_na_window.py +++ b/tests/engine_polars/test_invest_na_window.py @@ -5,18 +5,18 @@ Design: ``specs/invest_na_window_design.md`` (Revision 2). -The new solve param ``non_anticipativity_invest_periods`` ties +The new solve param ``shared_invest_periods`` ties ``v_invest`` / ``v_divest`` across stochastic branches over the resolved window. Composed with ``stochastic_invest_method=recourse`` (per-branch -invest columns to tie) and ``non_anticipativity_periods=[]`` (ops free) it +invest columns to tie) and ``shared_operation_periods=[]`` (ops free) it delivers the main mode. Fixture ``stoch_two_period_hedge.json`` (built via ``build_stoch_two_period_hedge.py``) re-uses the Slice E hedge topology with the branches FANNING AT t0 (the ``ws`` alternative), plus: - * ``hedge_na_window`` — fan-at-t0 + ``non_anticipativity_invest_periods - =[p2035]`` + ``non_anticipativity_periods=[]``. The tie reproduces + * ``hedge_na_window`` — fan-at-t0 + ``shared_invest_periods + =[p2035]`` + ``shared_operation_periods=[]``. The tie reproduces the Option-B hedge **RP = 173 250** BY CONSTRAINT (§9.3): p2035 invest shared across branches, p2040 invest per-branch (recourse). * ``hedge_ws`` — the same fan-at-t0 topology with the window UNSET — diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 203132dab..65459298c 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -2577,7 +2577,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -3200,6 +3200,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3365,28 +3376,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3464,6 +3453,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/build_stoch_two_period_free_ops.py b/tests/fixtures/build_stoch_two_period_free_ops.py index 6de84df5c..852c7a759 100644 --- a/tests/fixtures/build_stoch_two_period_free_ops.py +++ b/tests/fixtures/build_stoch_two_period_free_ops.py @@ -3,7 +3,7 @@ ``specs/sliceG_configurable_nonanticipativity_design.md`` §7). Goal: a two-period, t0-branching stochastic model that exercises the -operational non-anticipativity WINDOW knob (``non_anticipativity_periods``) +operational non-anticipativity WINDOW knob (``shared_operation_periods``) on a stochastic-group storage node, with a UNIQUE per-branch storage optimum so the "operations differ across branches" assertion is deterministic (design §7 / F6). @@ -42,16 +42,16 @@ cost`` 5 CUR/MWh so wind + stored energy is preferred to fresh base. Behaviour under the two scenarios (identical data, differing ONLY in the -``non_anticipativity_periods`` value): +``shared_operation_periods`` value): -* ``pinned_ops`` — ``non_anticipativity_periods`` UNSET -> legacy window +* ``pinned_ops`` — ``shared_operation_periods`` UNSET -> legacy window = realized_dispatch u fix_storage = the whole horizon. The storage NA family ties the branches' charge/discharge net-charge equal at every step, so with no branch inflow the ``v_state[resv]`` trajectory is IDENTICAL across ``mid`` / ``up`` / ``low`` — the branches cannot follow their opposite surplus/deficit timing and pay for it. (Byte-parity guard: this is the flag-off behaviour.) -* ``free_ops`` — ``non_anticipativity_periods = []`` -> empty window -> +* ``free_ops`` — ``shared_operation_periods = []`` -> empty window -> ZERO ``non_anticipativity_*`` rows -> each branch charges/discharges to its own optimum, so ``v_state[resv]`` DIVERGES across branches, and the objective is <= the pinned objective (freeing operations only relaxes @@ -104,7 +104,7 @@ def build() -> dict: # ``stochastics.json`` carries the pre-v70 schema; inject the v70 # ``solve.stochastic_invest_method`` opt-in and the v71 - # ``solve.non_anticipativity_periods`` window knob before import + # ``solve.shared_operation_periods`` window knob before import # (json_to_db validates against the embedded schema BEFORE the # idempotent v70/v71 migrations reconcile it). if not any(vl[0] == "stochastic_invest_methods" @@ -121,15 +121,16 @@ def build() -> dict: "How stochastic branches participate in investment " "(none/recourse).", "solve_advanced", ]) - if not any(pd[0] == "solve" and pd[1] == "non_anticipativity_periods" + if not any(pd[0] == "solve" and pd[1] == "shared_operation_periods" for pd in spec["parameter_definitions"]): # Array param (default null, no value list) — mirrors # realized_periods / fix_storage_periods (v71, Slice G). spec["parameter_definitions"].append([ - "solve", "non_anticipativity_periods", + "solve", "shared_operation_periods", _pack(None, None), None, - "Array of periods over which operational non-anticipativity " - "is enforced (unset = legacy window; [] = free ops).", + "Array of periods over which operations are SHARED (identical) " + "across stochastic branches (unset = legacy window; [] = free " + "ops).", "solve_advanced", ]) @@ -138,15 +139,15 @@ def build() -> dict: ["base", "System data (elec demand + wind + investable base)"], ["stoch", "include_stochastics + branch-varying wind + t0 branches " "+ stochastic-group storage (stochastic_invest_method=none)"], - ["freeops", "non_anticipativity_periods = [] (operations free " + ["freeops", "shared_operation_periods = [] (operations free " "from t0)"], ] spec["scenarios"] = [ ["free_ops", False, - "non_anticipativity_periods = [] -> operations branch freely; " + "shared_operation_periods = [] -> operations branch freely; " "shared invest, divergent v_state"], ["pinned_ops", False, - "non_anticipativity_periods UNSET -> legacy window; storage NA " + "shared_operation_periods UNSET -> legacy window; storage NA " "pins v_state equal across branches (byte-parity guard)"], ] spec["scenario_alternatives"] = [ @@ -299,7 +300,7 @@ def _branch_profile(early: float, late: float) -> dict: ["unit__outputNode", ["discharge", "elec"], "other_operational_cost", _pack(0.01, "float"), "stoch"], # ---- freeops: empty NA window (operations free from t0) -------- - ["solve", "stoch_2p_free", "non_anticipativity_periods", + ["solve", "stoch_2p_free", "shared_operation_periods", _pack({"value_type": "str", "data": []}, "array"), "freeops"], ] return spec diff --git a/tests/fixtures/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py index 7a857f281..513879f21 100644 --- a/tests/fixtures/build_stoch_two_period_hedge.py +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -146,7 +146,7 @@ def build() -> dict: "Mid-horizon reveal + stochastic-group storage node (T-S)"], ["hedge_na_window", False, "Invest-NA window main mode: fan-at-t0 + " - "non_anticipativity_invest_periods=[p2035] (RP = 173 250)"], + "shared_invest_periods=[p2035] (RP = 173 250)"], ["hedge_na_divest", False, "T-Divest: divest-eligible base under recourse+window — " "non_anticipativity_divest_p ties first-period divest too (F2)"], @@ -330,10 +330,10 @@ def build() -> dict: # per-branch (recourse), and free operations from t0. The tie # reproduces the Option-B hedge RP = 173 250 by CONSTRAINT (vs the # mid-horizon ``hedge`` scenario's shared trunk by construction). - ["solve", "stoch_2p_hedge", "non_anticipativity_invest_periods", + ["solve", "stoch_2p_hedge", "shared_invest_periods", _pack({"value_type": "str", "data": ["p2035"]}, "array"), "na_window"], - ["solve", "stoch_2p_hedge", "non_anticipativity_periods", + ["solve", "stoch_2p_hedge", "shared_operation_periods", _pack({"value_type": "str", "data": []}, "array"), "na_window"], # ---- divest: base divest-eligible (T-Divest, F2) --------------- diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index 2dfaa89b6..ec9ed333a 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -3629,7 +3629,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -4252,6 +4252,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -4406,28 +4417,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -4505,6 +4494,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index f3c47162e..e4e43bed7 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -8723,7 +8723,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -9346,6 +9346,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -9500,28 +9511,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -9599,6 +9588,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index 4adee5125..591ed881d 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -4571,7 +4571,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -5194,6 +5194,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -5348,28 +5359,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -5447,6 +5436,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/stoch_two_period_free_ops.json b/tests/fixtures/stoch_two_period_free_ops.json index f67d82d9a..f01bfd6ae 100644 --- a/tests/fixtures/stoch_two_period_free_ops.json +++ b/tests/fixtures/stoch_two_period_free_ops.json @@ -339,6 +339,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -1406,6 +1434,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2246,7 +2296,7 @@ "model", "version", [ - "NzE=", + "NzQ=", "float" ], null, @@ -2308,6 +2358,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2847,6 +2919,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3012,17 +3095,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3100,6 +3172,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", @@ -3287,6 +3381,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3927,6 +4043,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4575,6 +4709,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5211,6 +5363,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -5617,19 +5787,19 @@ ], [ "freeops", - "non_anticipativity_periods = [] (operations free from t0)" + "shared_operation_periods = [] (operations free from t0)" ] ], "scenarios": [ [ "free_ops", false, - "non_anticipativity_periods = [] -> operations branch freely; shared invest, divergent v_state" + "shared_operation_periods = [] -> operations branch freely; shared invest, divergent v_state" ], [ "pinned_ops", false, - "non_anticipativity_periods UNSET -> legacy window; storage NA pins v_state equal across branches (byte-parity guard)" + "shared_operation_periods UNSET -> legacy window; storage NA pins v_state equal across branches (byte-parity guard)" ] ], "scenario_alternatives": [ @@ -6303,7 +6473,7 @@ [ "solve", "stoch_2p_free", - "non_anticipativity_periods", + "shared_operation_periods", [ "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogW119", "array" diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json index f700e52b2..d28dd152c 100644 --- a/tests/fixtures/stoch_two_period_hedge.json +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -339,6 +339,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -1406,6 +1434,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2246,7 +2296,7 @@ "model", "version", [ - "NzI=", + "NzQ=", "float" ], null, @@ -2308,6 +2358,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2847,6 +2919,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3012,28 +3095,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3111,6 +3172,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", @@ -3298,6 +3381,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3938,6 +4043,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4586,6 +4709,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5222,6 +5363,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -5670,7 +5829,7 @@ [ "hedge_na_window", false, - "Invest-NA window main mode: fan-at-t0 + non_anticipativity_invest_periods=[p2035] (RP = 173 250)" + "Invest-NA window main mode: fan-at-t0 + shared_invest_periods=[p2035] (RP = 173 250)" ], [ "hedge_na_divest", @@ -6376,7 +6535,7 @@ [ "solve", "stoch_2p_hedge", - "non_anticipativity_invest_periods", + "shared_invest_periods", [ "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", "array" @@ -6386,7 +6545,7 @@ [ "solve", "stoch_2p_hedge", - "non_anticipativity_periods", + "shared_operation_periods", [ "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogW119", "array" diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index 7bab108c5..c1f40cd18 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -2577,7 +2577,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -3200,6 +3200,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3365,28 +3376,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3464,6 +3453,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index a0c0c8a5c..b1649904b 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -2577,7 +2577,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -3200,6 +3200,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -3365,28 +3376,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -3464,6 +3453,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index 38fb921a8..d024e96fd 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -3517,7 +3517,7 @@ "model", "version", [ - "NzM=", + "NzQ=", "float" ], null, @@ -4140,6 +4140,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -4294,28 +4305,6 @@ "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", "timeline" ], - [ - "solve", - "non_anticipativity_invest_periods", - [ - null, - null - ], - null, - "Array of periods over which investment is non-anticipative (v_invest and v_divest tied across stochastic branches \u2014 a single shared here-and-now decision). Unset (default) or an empty Array means NO tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- ties investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", - "solve_advanced" - ], - [ - "solve", - "non_anticipativity_periods", - [ - null, - null - ], - null, - "Array of periods over which operational non-anticipativity (storage / online / reserve dispatch tied across stochastic branches) is enforced. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list ties operations only over those periods' timesteps.", - "solve_advanced" - ], [ "solve", "period_timeset", @@ -4393,6 +4382,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", diff --git a/tests/update_flextool/test_v71_non_anticipativity_periods.py b/tests/update_flextool/test_v71_non_anticipativity_periods.py index 5fc2ffab4..f0843027b 100644 --- a/tests/update_flextool/test_v71_non_anticipativity_periods.py +++ b/tests/update_flextool/test_v71_non_anticipativity_periods.py @@ -76,15 +76,18 @@ def _db_version(url: str) -> int: def test_non_anticipativity_periods_definition_present(tmp_path: Path) -> None: - """``solve.non_anticipativity_periods`` exists as an unset Array param - (no default, no value list) grouped under ``solve_advanced``.""" + """At HEAD the operational shared-window param exists as an unset Array + (no default, no value list) grouped under ``solve_advanced``. Added at + v71 as ``non_anticipativity_periods`` and renamed to + ``shared_operation_periods`` at v74 — this fixture migrates to HEAD, so + it carries the current (renamed) name.""" db = _migrated_db(tmp_path) try: pdef = db.get_parameter_definition_item( entity_class_name="solve", - name="non_anticipativity_periods", + name="shared_operation_periods", ) - assert pdef, "non_anticipativity_periods not added by migration" + assert pdef, "shared_operation_periods not present at HEAD" # Array param — no scalar default, no bound value list. assert pdef["default_value"] is None assert pdef["default_type"] is None @@ -97,15 +100,16 @@ def test_non_anticipativity_periods_definition_present(tmp_path: Path) -> None: def test_migration_adds_no_per_solve_value(tmp_path: Path) -> None: """The v71 migration adds the definition ONLY — never a per-solve value — so every migrated solve resolves to the unset (legacy-window) - case and stays byte-identical.""" + case and stays byte-identical. (Param renamed to + ``shared_operation_periods`` at v74; this fixture is at HEAD.)""" db = _migrated_db(tmp_path) try: values = [ v for v in db.get_parameter_value_items() - if v["parameter_definition_name"] == "non_anticipativity_periods" + if v["parameter_definition_name"] == "shared_operation_periods" ] assert values == [], ( - "migration must not author any non_anticipativity_periods value" + "migration must not author any shared_operation_periods value" ) finally: db.close() diff --git a/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py b/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py index 90bc246e3..37708f2c0 100644 --- a/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py +++ b/tests/update_flextool/test_v72_non_anticipativity_invest_periods.py @@ -72,15 +72,18 @@ def _db_version(url: str) -> int: def test_invest_periods_definition_present(tmp_path: Path) -> None: - """``solve.non_anticipativity_invest_periods`` exists as an unset Array - param (no default, no value list) grouped under ``solve_advanced``.""" + """At HEAD the investment shared-window param exists as an unset Array + param (no default, no value list) grouped under ``solve_advanced``. + Added at v72 as ``non_anticipativity_invest_periods`` and renamed to + ``shared_invest_periods`` at v74 — this fixture migrates to HEAD, so it + carries the current (renamed) name.""" db = _migrated_db(tmp_path) try: pdef = db.get_parameter_definition_item( entity_class_name="solve", - name="non_anticipativity_invest_periods", + name="shared_invest_periods", ) - assert pdef, "non_anticipativity_invest_periods not added by migration" + assert pdef, "shared_invest_periods not present at HEAD" assert pdef["default_value"] is None assert pdef["default_type"] is None assert not pdef["parameter_value_list_name"] @@ -92,17 +95,18 @@ def test_invest_periods_definition_present(tmp_path: Path) -> None: def test_migration_adds_no_per_solve_value(tmp_path: Path) -> None: """The v72 migration adds the definition ONLY — never a per-solve value — so every migrated solve resolves to the unset (no-tie) case - and stays byte-identical.""" + and stays byte-identical. (Param renamed to ``shared_invest_periods`` + at v74; this fixture is at HEAD.)""" db = _migrated_db(tmp_path) try: values = [ v for v in db.get_parameter_value_items() if v["parameter_definition_name"] - == "non_anticipativity_invest_periods" + == "shared_invest_periods" ] assert values == [], ( "migration must not author any " - "non_anticipativity_invest_periods value" + "shared_invest_periods value" ) finally: db.close() diff --git a/tests/update_flextool/test_v74_rename_and_reserve_duration.py b/tests/update_flextool/test_v74_rename_and_reserve_duration.py new file mode 100644 index 000000000..a23cfc2d3 --- /dev/null +++ b/tests/update_flextool/test_v74_rename_and_reserve_duration.py @@ -0,0 +1,228 @@ +"""Tests for the v74 database migration — a single folded bump that: + +1. **Renames** the two (unreleased) stochastic shared-decision window + parameters on the ``solve`` class to positive, non-negated names: + + * ``non_anticipativity_periods`` -> ``shared_operation_periods`` + * ``non_anticipativity_invest_periods`` -> ``shared_invest_periods`` + + The rename is collision-safe and carries each parameter's existing + per-solve Array *values* by definition id, so a migrated DB keeps + identical behaviour under the new names. + +2. **Adds** ``reserve__upDown__group.reserve_duration`` for DBs that predate + the parameter (``[h]`` scalar, default null, no value list; storage + energy-adequacy coupling for reserves, issue #322). Idempotent — a + no-op when the definition is already present. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from spinedb_api import Array, DatabaseMapping, from_database, import_data + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + +_OLD_TO_NEW = { + "non_anticipativity_periods": "shared_operation_periods", + "non_anticipativity_invest_periods": "shared_invest_periods", +} + + +def test_v74_version_constant_is_at_least_74() -> None: + """The engine must report a schema version >= 74 — the rename + + reserve_duration lower bound.""" + assert FLEXTOOL_DB_VERSION >= 74 + + +def _migrated_db(tmp_path: Path, fixture: str) -> DatabaseMapping: + """Build a fixture DB from JSON, migrate it to HEAD, return an open + mapping.""" + db_path = tmp_path / "fx.sqlite" + url = json_to_db(FIXTURES_DIR / fixture, db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- rename: old names gone, new names present at HEAD ------------------- + + +def test_na_params_renamed_at_head(tmp_path: Path) -> None: + """At HEAD the two window params carry the positive names; the old + non_anticipativity_* names are gone.""" + db = _migrated_db(tmp_path, "lh2_three_region.json") + try: + for old, new in _OLD_TO_NEW.items(): + assert not db.get_parameter_definition_item( + entity_class_name="solve", name=old, + ), f"{old} should have been renamed away at v74" + pdef = db.get_parameter_definition_item( + entity_class_name="solve", name=new, + ) + assert pdef, f"{new} not present at HEAD" + # Still an unset Array under solve_advanced. + assert pdef["default_value"] is None + assert pdef["default_type"] is None + assert not pdef["parameter_value_list_name"] + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_rename_carries_values(tmp_path: Path) -> None: + """A fixture that SETS the window params keeps those values under the + new names after migrating to HEAD (values follow the definition id).""" + db = _migrated_db(tmp_path, "stoch_two_period_hedge.json") + try: + names = { + v["parameter_definition_name"] + for v in db.get_parameter_value_items() + } + # The hedge fixture authors both window values on stoch_2p_hedge. + assert "shared_invest_periods" in names, ( + "shared_invest_periods value lost in rename" + ) + assert "shared_operation_periods" in names, ( + "shared_operation_periods value lost in rename" + ) + for old in _OLD_TO_NEW: + assert old not in names, f"stale {old} value survived the rename" + finally: + db.close() + + +# --- reserve_duration added on the reserve relationship class ----------- + + +def _build_v73_db(url: str) -> None: + """Create a minimal v73 DB carrying the OLD-named window params (with a + value) on ``solve`` and a ``reserve__upDown__group`` class WITHOUT + reserve_duration — so the v74 block is the ONLY path that renames / + adds. ``model.version`` default pinned to 73 so + ``migrate_database(..., up_to=74)`` runs only the v74 step.""" + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ["reserve", ()], + ["upDown", ()], + ["group", ()], + ["reserve__upDown__group", ("reserve", "upDown", "group")], + ], + parameter_definitions=[ + ["model", "version", 73.0, None, "Database version."], + ["solve", "non_anticipativity_periods", None, None, + "Operational non-anticipativity window (pre-rename)."], + ["solve", "non_anticipativity_invest_periods", None, None, + "Investment non-anticipativity window (pre-rename)."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + parameter_values=[ + ["solve", "s1", "non_anticipativity_invest_periods", + Array(["p1"]), "Base"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v73 DB (old window names + reserve class)") + + +def test_migration_renames_adds_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v74 DB migrates to exactly v74: the window params are renamed + (carrying their values), reserve_duration is added, and re-running the + v74 step is a no-op.""" + db_path = tmp_path / "v73_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v73_db(url) + start_version = _db_version(url) + assert start_version < 74, ( + "seed DB must start below v74 so the v74 block is exercised" + ) + + migrate_database(url, up_to=74) + assert _db_version(url) == 74 + + def _assert_state(db: DatabaseMapping) -> None: + # Rename: old gone, new present. + for old, new in _OLD_TO_NEW.items(): + assert not db.get_parameter_definition_item( + entity_class_name="solve", name=old, + ), f"{old} not renamed" + assert db.get_parameter_definition_item( + entity_class_name="solve", name=new, + ), f"{new} missing after rename" + # Value carried to the new invest name. + inv_vals = [ + v for v in db.get_parameter_value_items() + if v["parameter_definition_name"] == "shared_invest_periods" + ] + assert len(inv_vals) == 1, "invest-window value not carried by rename" + assert list(from_database( + inv_vals[0]["value"], inv_vals[0]["type"]).values) == ["p1"] + # reserve_duration added on the reserve relationship class. + rd = db.get_parameter_definition_item( + entity_class_name="reserve__upDown__group", + name="reserve_duration", + ) + assert rd, "reserve_duration not added by v74" + assert rd["default_value"] is None + assert rd["default_type"] is None + assert not rd["parameter_value_list_name"] + + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + _assert_state(db) + + # Idempotent re-run of the v74 step. + migrate_database(url, up_to=74) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + _assert_state(db) + + +# --- the committed master template is up to date ------------------------ + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (rename + reserve_duration) was committed.""" + assert sync_master_template(verify_only=True) is True From eb16acbd782eaf6e545feeba6d05442de0c4c3c7 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Fri, 2 Oct 2026 13:01:17 +0300 Subject: [PATCH 60/87] fix(param-shapes): failed-cast tokens no longer broadcast as missing axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit broadcast_to_period_time cast axis tokens with strict=False BEFORE the null-pattern split, so an unknown/inactive period or timestep token became null and was then misread as a "missing axis" default — broadcasting that row across the whole axis (reproduced: a MAP_TIME row (b,t01) got both 50 and 60). Fix (spec fix_map_reading.md §3.4): capture period/time null masks BEFORE the cast, drop rows whose token was non-null before but null after (unknown/inactive; DEBUG-logged), and drive the null-pattern split off the PRE-cast masks. Applied to all three branches (MAP_PERIOD_TIME four-way, MAP_PERIOD, MAP_TIME). No-op for valid data by construction (pre-cast masks == post-cast nulls when nothing fails), so byte-identical for the normal case. Tests: new test_broadcast_null_cast_fix.py (6, verified load-bearing against the pre-fix logic). Cascade/byte-parity gate 822 passed byte-identical; param-shape/instant-flow/silent-default suite 66 passed; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_param_shapes.py | 71 ++++- .../test_broadcast_null_cast_fix.py | 265 ++++++++++++++++++ 2 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 tests/engine_polars/test_broadcast_null_cast_fix.py diff --git a/flextool/engine_polars/_param_shapes.py b/flextool/engine_polars/_param_shapes.py index b8c19f403..890afff65 100644 --- a/flextool/engine_polars/_param_shapes.py +++ b/flextool/engine_polars/_param_shapes.py @@ -61,6 +61,7 @@ """ from __future__ import annotations +import logging from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING @@ -76,6 +77,8 @@ ) from flextool.engine_polars._solve_state import FlexToolConfigError +_LOGGER = logging.getLogger(__name__) + if TYPE_CHECKING: from flextool.engine_polars._input_source import InputSource @@ -1306,9 +1309,42 @@ def broadcast_to_period_time( # contract is "Enum when global vocabulary is active"; the resolver # reads frames in String land via ``InputSource.parameter*``, so the # cast happens here at the broadcast boundary. + # + # Failed-cast guard (spec fix_map_reading.md §3.4). ``cast_frame_axes`` + # runs with ``strict=False``, so a period/time token that is NOT in the + # model's Enum vocabulary (an unknown or inactive period / timestep) + # silently becomes ``null`` on cast. The null-pattern split below reads + # a ``null`` axis token as a "missing axis" default and broadcasts that + # row across the WHOLE axis — spreading a bogus value over every (d, t) + # and producing duplicate / wrong keys (reproduced: a MAP_TIME row for + # ``t01`` ended up carrying both 50 and 60). Guard it: record which + # rows carried a NON-null token BEFORE the cast, then after the cast + # DROP the rows whose token was non-null before but null after (the cast + # failures), and drive the null-pattern split off the PRE-cast masks — + # never the post-cast nulls. For data whose tokens are all valid/active + # (the normal case) nothing is dropped and the pre-cast masks equal the + # post-cast nulls, so this is a byte-for-byte no-op. + _axis_cols = [c for c in ("period", "t") if c in lf.collect_schema().names()] + _mask_cols = {c: f"__{c}_was_null" for c in _axis_cols} + if _axis_cols: + lf = lf.with_columns( + pl.col(c).is_null().alias(_mask_cols[c]) for c in _axis_cols) _enums = get_global_axis_enums() if _enums is not None: lf = cast_frame_axes(lf, _enums) + for c in _axis_cols: + # Non-null before the cast, null after → unknown/inactive token + # outside the model's vocabulary. Drop it (do not let the split + # treat it as a missing-axis default). + _failed = (~pl.col(_mask_cols[c])) & pl.col(c).is_null() + if _LOGGER.isEnabledFor(logging.DEBUG): + _n = lf.select(_failed.sum().alias("n")).collect().item() + if _n: + _LOGGER.debug( + "broadcast_to_period_time: dropped %d row(s) with " + "unknown/inactive '%s' token (failed Enum cast)", + _n, c) + lf = lf.filter(~_failed) shape = resolved.shape if shape in (Shape.MAP_PERIOD_TIME, Shape.MAP_TIME_PERIOD): @@ -1338,30 +1374,31 @@ def broadcast_to_period_time( # keyed, so only the ``lf_full`` branch is non-empty and the # result is byte-identical to the old inner-join. dt_lf = period_filter.lazy().select("d", "t").unique() + # Drive the four-way split off the PRE-cast null masks (§3.4) — a + # post-cast null may be a failed cast, not an authored default. + _d_null = pl.col(_mask_cols["period"]) + _t_null = pl.col(_mask_cols["t"]) lf_dt = (lf.pipe(rename_to_axis, {"period": "d"}) - .select(*entity_keys, "d", "t", "value")) + .select(*entity_keys, "d", "t", "value", + _mask_cols["period"], _mask_cols["t"])) # Fully-keyed rows: exact (d, t) match against the active grid. - lf_full = (lf_dt.filter(pl.col("d").is_not_null() - & pl.col("t").is_not_null()) + lf_full = (lf_dt.filter(~_d_null & ~_t_null) .join(dt_lf, on=["d", "t"], how="inner") .select(*entity_keys, "d", "t", "value")) # Period-only default (null t): broadcast across every active # timestep of the matching active period. - lf_period = (lf_dt.filter(pl.col("d").is_not_null() - & pl.col("t").is_null()) + lf_period = (lf_dt.filter(~_d_null & _t_null) .select(*entity_keys, "d", "value") .join(dt_lf, on="d", how="inner") .select(*entity_keys, "d", "t", "value")) # Time-only default (null d): broadcast across every active # period of the matching active timestep. - lf_time = (lf_dt.filter(pl.col("d").is_null() - & pl.col("t").is_not_null()) + lf_time = (lf_dt.filter(_d_null & ~_t_null) .select(*entity_keys, "t", "value") .join(dt_lf, on="t", how="inner") .select(*entity_keys, "d", "t", "value")) # Scalar default (both null): broadcast across the whole grid. - lf_scalar = (lf_dt.filter(pl.col("d").is_null() - & pl.col("t").is_null()) + lf_scalar = (lf_dt.filter(_d_null & _t_null) .select(*entity_keys, "value") .join(dt_lf, how="cross") .select(*entity_keys, "d", "t", "value")) @@ -1382,12 +1419,16 @@ def broadcast_to_period_time( # broadcast independently across the active-period universe, # then concatenate. d_lf = period_filter.lazy().select("d").unique() + # Split off the PRE-cast null mask (§3.4): a post-cast null may be + # an unknown/inactive period that failed the cast, not a scalar + # default — those rows were already dropped above. + _d_null = pl.col(_mask_cols["period"]) lf_p = lf.pipe(rename_to_axis, {"period": "d"}) - lf_scalar = (lf_p.filter(pl.col("d").is_null()) + lf_scalar = (lf_p.filter(_d_null) .select(*entity_keys, "value") .join(d_lf, how="cross") .select(*entity_keys, "d", "value")) - lf_explicit = (lf_p.filter(pl.col("d").is_not_null()) + lf_explicit = (lf_p.filter(~_d_null) .select(*entity_keys, "d", "value") .join(d_lf, on="d", how="inner") .select(*entity_keys, "d", "value")) @@ -1405,11 +1446,15 @@ def broadcast_to_period_time( # ``network_coal_wind_battery_co2_fullYear_availability`` where # ``coal_plant`` is MAP_TIME but ``wind_plant`` is scalar 0.7. t_lf = period_filter.lazy().select("t").unique() - lf_scalar = (lf.filter(pl.col("t").is_null()) + # Split off the PRE-cast null mask (§3.4): a post-cast null may be + # an unknown/inactive timestep that failed the cast, not a scalar + # default — those rows were already dropped above. + _t_null = pl.col(_mask_cols["t"]) + lf_scalar = (lf.filter(_t_null) .select(*entity_keys, "value") .join(t_lf, how="cross") .select(*entity_keys, "t", "value")) - lf_explicit = (lf.filter(pl.col("t").is_not_null()) + lf_explicit = (lf.filter(~_t_null) .select(*entity_keys, "t", "value") .join(t_lf, on="t", how="inner") .select(*entity_keys, "t", "value")) diff --git a/tests/engine_polars/test_broadcast_null_cast_fix.py b/tests/engine_polars/test_broadcast_null_cast_fix.py new file mode 100644 index 000000000..888436958 --- /dev/null +++ b/tests/engine_polars/test_broadcast_null_cast_fix.py @@ -0,0 +1,265 @@ +"""Failed-cast guard for :func:`broadcast_to_period_time` (spec +``fix_map_reading.md`` §3.4). + +``broadcast_to_period_time`` casts the period / time axis columns against +the model's Enum vocabulary with ``strict=False``, so a token that is NOT +in the vocabulary (an unknown or inactive period / timestep) silently +becomes ``null``. Before the fix, the downstream null-pattern split read +that ``null`` as a "missing axis" default and broadcast the row across the +WHOLE axis — producing duplicate / wrong ``(e, d, t)`` keys (the reported +repro: a ``MAP_TIME`` row for ``t01`` ended up carrying both 50 and 60). + +The fix captures the per-row null state BEFORE the cast, drops the rows +whose token failed the cast (non-null before, null after), and drives the +split off the pre-cast masks. For data whose tokens are all valid/active +(the normal case) nothing is dropped and the result is byte-for-byte +unchanged. + +These tests construct ``ResolvedShape`` frames directly and call the +broadcaster under an active global axis-enum vocabulary (the production +path — that is when the strict=False cast runs). +""" +from __future__ import annotations + +import polars as pl +import pytest + +from flextool.engine_polars._axis_enums import ( + reset_global_axis_enums, + set_global_axis_enums, +) +from flextool.engine_polars._param_shapes import ( + ResolvedShape, + Shape, + broadcast_to_period_time, +) + +# Two active periods × two active timesteps — the model's full vocabulary. +_PERIODS = ["p2025", "p2030"] +_TIMES = ["t01", "t02"] +_D_ENUM = pl.Enum(_PERIODS) +_T_ENUM = pl.Enum(_TIMES) + + +@pytest.fixture() +def axis_enums(): + """Activate the cascade-wide axis-enum vocabulary (production path).""" + token = set_global_axis_enums({"d": _D_ENUM, "t": _T_ENUM}) + try: + yield + finally: + reset_global_axis_enums(token) + + +def _dt() -> pl.DataFrame: + """The active-solve (d, t) grid, carrying Enum dtypes as the cascade + does after ``apply_derived_a`` runs.""" + return pl.DataFrame( + { + "d": pl.Series( + [p for p in _PERIODS for _ in _TIMES], dtype=_D_ENUM), + "t": pl.Series(_TIMES * len(_PERIODS), dtype=_T_ENUM), + } + ) + + +def _collect(param) -> pl.DataFrame: + frame = param.frame + if isinstance(frame, pl.LazyFrame): + frame = frame.collect() + # Normalise dim dtypes to Utf8 so assertions compare on labels. + casts = [pl.col(c).cast(pl.Utf8) for c in frame.columns if c != "value"] + return frame.with_columns(casts) + + +def _assert_no_dupes(df: pl.DataFrame, keys: list[str]) -> None: + assert df.height == df.select(keys).unique().height, ( + f"duplicate {keys} keys:\n{df}") + + +# --------------------------------------------------------------------------- +# Bug: an unknown / inactive token must NOT be broadcast across the axis. +# --------------------------------------------------------------------------- + + +def test_map_time_unknown_token_not_broadcast(axis_enums): + """A MAP_TIME row with an unknown timestep token must be dropped, not + cross-joined across every active timestep (the reported repro where + (b, t01) carried both 50 and 60).""" + frame = pl.DataFrame( + { + "name": ["fg", "fg"], + "t": ["t01", "UNKNOWN_T"], + "value": [50.0, 60.0], + } + ) + resolved = ResolvedShape( + shape=Shape.MAP_TIME, + frame=frame, + entity_dim_columns=("name",), + period_index_column=None, + time_index_column="t", + ) + out = _collect(broadcast_to_period_time(resolved, "group", _dt())) + _assert_no_dupes(out, ["group", "t"]) + # The unknown token's value never appears, and the one valid row is + # kept exactly once (not spread across t02). + assert 60.0 not in out["value"].to_list() + assert sorted(out["t"].to_list()) == ["t01"] + assert out.sort("t").to_dicts() == [ + {"group": "fg", "t": "t01", "value": 50.0}] + + +def test_map_period_unknown_token_not_broadcast(axis_enums): + """Same guard for the MAP_PERIOD null-index branch.""" + frame = pl.DataFrame( + { + "name": ["fg", "fg"], + "period": ["p2025", "UNKNOWN_P"], + "value": [50.0, 60.0], + } + ) + resolved = ResolvedShape( + shape=Shape.MAP_PERIOD, + frame=frame, + entity_dim_columns=("name",), + period_index_column="period", + time_index_column=None, + ) + out = _collect(broadcast_to_period_time(resolved, "group", _dt())) + _assert_no_dupes(out, ["group", "d"]) + assert 60.0 not in out["value"].to_list() + assert sorted(out["d"].to_list()) == ["p2025"] + assert out.sort("d").to_dicts() == [ + {"group": "fg", "d": "p2025", "value": 50.0}] + + +def test_map_period_time_unknown_token_not_broadcast(axis_enums): + """MAP_PERIOD_TIME four-way split: a row whose period token is unknown + must not fall into the time-only default branch and be broadcast + across every active period (which created the duplicate (d, t) key).""" + frame = pl.DataFrame( + { + "name": ["fg", "fg"], + "period": ["p2025", "UNKNOWN_P"], + "t": ["t01", "t01"], + "value": [50.0, 60.0], + } + ) + resolved = ResolvedShape( + shape=Shape.MAP_PERIOD_TIME, + frame=frame, + entity_dim_columns=("name",), + period_index_column="period", + time_index_column="t", + ) + out = _collect(broadcast_to_period_time(resolved, "group", _dt())) + _assert_no_dupes(out, ["group", "d", "t"]) + assert 60.0 not in out["value"].to_list() + assert out.sort(["d", "t"]).to_dicts() == [ + {"group": "fg", "d": "p2025", "t": "t01", "value": 50.0}] + + +def test_map_period_time_unknown_time_token_not_broadcast(axis_enums): + """Mirror of the above, but the unknown token is on the time axis — it + must not fall into the period-only default branch.""" + frame = pl.DataFrame( + { + "name": ["fg", "fg"], + "period": ["p2025", "p2025"], + "t": ["t01", "UNKNOWN_T"], + "value": [50.0, 60.0], + } + ) + resolved = ResolvedShape( + shape=Shape.MAP_PERIOD_TIME, + frame=frame, + entity_dim_columns=("name",), + period_index_column="period", + time_index_column="t", + ) + out = _collect(broadcast_to_period_time(resolved, "group", _dt())) + _assert_no_dupes(out, ["group", "d", "t"]) + assert 60.0 not in out["value"].to_list() + assert out.sort(["d", "t"]).to_dicts() == [ + {"group": "fg", "d": "p2025", "t": "t01", "value": 50.0}] + + +# --------------------------------------------------------------------------- +# No-op for all-valid data: the fix must not change the normal broadcast. +# --------------------------------------------------------------------------- + + +def test_all_valid_full_broadcast_unchanged(axis_enums): + """Mixed authoring with ALL-valid tokens exercises every sub-branch of + the MAP_PERIOD_TIME split and must produce the full, unchanged + broadcast with no dropped or duplicated rows.""" + frame = pl.DataFrame( + { + "name": ["fgA", "fgB", "fgC", "fgD"], + # fgA: fully keyed 2-D cell. + # fgB: period-only default (null t) → broadcast over t01, t02. + # fgC: time-only default (null d) → broadcast over p2025, p2030. + # fgD: scalar default (both null) → broadcast over whole grid. + "period": ["p2025", "p2025", None, None], + "t": ["t01", None, "t01", None], + "value": [10.0, 20.0, 30.0, 40.0], + } + ) + resolved = ResolvedShape( + shape=Shape.MAP_PERIOD_TIME, + frame=frame, + entity_dim_columns=("name",), + period_index_column="period", + time_index_column="t", + ) + out = _collect(broadcast_to_period_time(resolved, "group", _dt())) + _assert_no_dupes(out, ["group", "d", "t"]) + got = { + (r["group"], r["d"], r["t"]): r["value"] for r in out.to_dicts() + } + expected = { + ("fgA", "p2025", "t01"): 10.0, + ("fgB", "p2025", "t01"): 20.0, + ("fgB", "p2025", "t02"): 20.0, + ("fgC", "p2025", "t01"): 30.0, + ("fgC", "p2030", "t01"): 30.0, + ("fgD", "p2025", "t01"): 40.0, + ("fgD", "p2025", "t02"): 40.0, + ("fgD", "p2030", "t01"): 40.0, + ("fgD", "p2030", "t02"): 40.0, + } + assert got == expected + + +def test_all_valid_map_time_and_map_period_unchanged(axis_enums): + """All-valid 1-D MAP_TIME / MAP_PERIOD rows broadcast to their native + axis exactly, with no rows dropped.""" + time_frame = pl.DataFrame( + {"name": ["fg", "fg"], "t": ["t01", "t02"], "value": [10.0, 20.0]} + ) + out_t = _collect(broadcast_to_period_time( + ResolvedShape( + shape=Shape.MAP_TIME, frame=time_frame, + entity_dim_columns=("name",), period_index_column=None, + time_index_column="t"), + "group", _dt())) + assert out_t.sort("t").to_dicts() == [ + {"group": "fg", "t": "t01", "value": 10.0}, + {"group": "fg", "t": "t02", "value": 20.0}, + ] + + period_frame = pl.DataFrame( + {"name": ["fg", "fg"], "period": ["p2025", "p2030"], + "value": [10.0, 20.0]} + ) + out_d = _collect(broadcast_to_period_time( + ResolvedShape( + shape=Shape.MAP_PERIOD, frame=period_frame, + entity_dim_columns=("name",), period_index_column="period", + time_index_column=None), + "group", _dt())) + assert out_d.sort("d").to_dicts() == [ + {"group": "fg", "d": "p2025", "value": 10.0}, + {"group": "fg", "d": "p2030", "value": 20.0}, + ] From fb28c9a3042aeba749f5658864db71a311665a5b Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Fri, 2 Oct 2026 13:45:52 +0300 Subject: [PATCH 61/87] fix(param-shapes): per-row period/time classification for dual-axis params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One parameter authored with different Map shapes across entities (e.g. flowGroup.min_instant_flow: one entity Map(period), another Map(time)) was silently corrupted: the reader chose ONE positional index-column list for the whole parameter, so tokens landed in the wrong axis column and the production scenario-trim then dropped the misplaced rows — outcome flipped with DB row order, no warning. (spec fix_map_reading.md §3.1-§3.3, rev 2.) Registry-scoped fix (entered ONLY for the 48 PERIOD_TIME_PARAMS keys — those whose allow-list contains MAP_PERIOD/MAP_TIME/MAP_PERIOD_TIME): - Reader (_spinedb_reader): _unroll_period_time_rows classifies each Map level per-row (index_name -> silent-label value-domain vs global axis vocab -> raise on ambiguity), canonical period-before-t column order, each row writes only its own axes. Shared classifier reused by parameter_shape_info. - New parameter_shape_variants on SpineDbReader + InMemoryReader + optional on the InputSource Protocol. - Resolver (_param_shapes): per-variant allow-list check — accepts a combined MAP_PERIOD_TIME for commodity.price (every row shape individually allowed), rejects a genuine 2-D row there, and rejects a time/2-D map on a period-only param. Raises FlexToolConfigError naming the offending entities; one INFO line per mixed (class,param). Behavior change (CHANGELOG, spec §5 decided): an unaccepted shape on a period-only parameter now RAISES (naming the entity) instead of being silently dropped; keys matching both vocabularies raise FlexDataIntegrityError. Byte-identical by construction for every non-PERIOD_TIME_PARAMS key and every single-shape registry param (new branch not entered / same frame). Verified: golden snapshot 5 passed (reader-level byte-identical across corpus); dual-axis matrix 42 passed (13 cases x row-order x reader{enums,no-enums}); registry pin 4 passed; reader/shape regression 106 passed; cascade byte-parity 822 passed byte-identical. ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- CHANGELOG.md | 13 + flextool/engine_polars/_inmemory_reader.py | 37 ++ flextool/engine_polars/_input_source.py | 22 + flextool/engine_polars/_param_shapes.py | 168 +++++- flextool/engine_polars/_spinedb_reader.py | 441 +++++++++++++++- .../test_mixed_shape_dual_axis.py | 493 ++++++++++++++++++ .../engine_polars/test_mixed_shape_golden.py | 88 ++++ .../test_period_time_params_registry.py | 91 ++++ 8 files changed, 1331 insertions(+), 22 deletions(-) create mode 100644 tests/engine_polars/test_mixed_shape_dual_axis.py create mode 100644 tests/engine_polars/test_mixed_shape_golden.py create mode 100644 tests/engine_polars/test_period_time_params_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 072f4ada8..f222e5d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ shared-decision window parameters use positive names: `shared_invest_periods` / `shared_operation_periods` name the periods whose decisions are *shared* (identical) across stochastic branches. +- **Mixed period/time Map shapes within one parameter.** A parameter can + now be authored with different Map shapes for different entities (for + example a `flowGroup.min_instant_flow` that is a `Map(period)` on one + group and a `Map(time)` on another). Each row keeps its own index + semantics regardless of the order of rows in the database, instead of + being silently misplaced or dropped. As a consequence of classifying + every row, a Map shape that a parameter does not accept — for example a + time Map (or any other non-accepted shape) on a period-only parameter — + now **raises** an error that names the offending entity, instead of + being silently ignored. Databases that relied on such input being + dropped will stop with a clear message identifying the entity and + parameter to fix. + - **Construction lead time.** New per-entity parameters `construction_lead_time` ([years]) and `construction_lead_time_method` (`immediate` / `closest_seam` / `previous_seam` / `next_seam`) model diff --git a/flextool/engine_polars/_inmemory_reader.py b/flextool/engine_polars/_inmemory_reader.py index 86497b6da..0cf00b878 100644 --- a/flextool/engine_polars/_inmemory_reader.py +++ b/flextool/engine_polars/_inmemory_reader.py @@ -121,6 +121,43 @@ def parameter_shape_info(self, entity_class: str, out.append(c) return out + def parameter_shape_variants(self, entity_class: str, + parameter_name: str, + ) -> "set[tuple[str, ...]]": + """Spec fix_map_reading.md §3.2 — distinct per-row classified axis + paths for a PERIOD_TIME_PARAMS parameter, inferred from the + frame's null patterns. + + InMemoryReader callers author frames already in post-resolution + shape (columns ``period`` / ``t`` / ``time``). Each row's variant + is the tuple of canonical axes whose column is non-null, in + canonical order (period before time). Returns an empty set for + non-PERIOD_TIME keys so the resolver falls back to its combined + allow-list check. + """ + from flextool.engine_polars._param_shapes import PERIOD_TIME_PARAMS + if (entity_class, parameter_name) not in PERIOD_TIME_PARAMS: + return set() + df = self.parameter(entity_class, parameter_name) + has_period = "period" in df.columns + tcol = "t" if "t" in df.columns else ( + "time" if "time" in df.columns else None + ) + if not has_period and tcol is None: + # Scalar-only frame (or no period/time columns). + return {()} if df.height > 0 else set() + sel_cols = ([c for c in ("period",) if has_period] + + ([tcol] if tcol is not None else [])) + variants: set[tuple[str, ...]] = set() + for row in df.select(sel_cols).iter_rows(named=True): + path: list[str] = [] + if has_period and row.get("period") is not None: + path.append("period") + if tcol is not None and row.get(tcol) is not None: + path.append("time") + variants.add(tuple(path)) + return variants + # ------------------------------------------------------------------ # Diagnostics diff --git a/flextool/engine_polars/_input_source.py b/flextool/engine_polars/_input_source.py index 8ed5e2951..e65b2adc8 100644 --- a/flextool/engine_polars/_input_source.py +++ b/flextool/engine_polars/_input_source.py @@ -275,6 +275,28 @@ def parameter_shape_info(self, docstring for details. """ + def parameter_shape_variants(self, + entity_class: str, + parameter_name: str, + ) -> "set[tuple[str, ...]]": + """Return the distinct per-row classified axis paths for a + period/time registry parameter (spec ``fix_map_reading.md`` §3.2). + + Each element is a tuple of canonical axis labels + (``("period",)`` / ``("time",)`` / ``("period", "time")`` / + ``()`` for scalar). Returns an empty set for parameters outside + ``PERIOD_TIME_PARAMS`` (or when classification is not possible), + in which case :func:`resolve_param_shape` falls back to checking + the combined shape against the allow-list. + + This member is OPTIONAL: the resolver consumes it via ``getattr`` + so test-only stubs that implement only :meth:`parameter_shape_info` + keep working. It is declared here so the two production readers + (:class:`SpineDbReader`, :class:`InMemoryReader`) satisfy the + ``@runtime_checkable`` isinstance check used at the + ``load_flextool`` boundary. + """ + @runtime_checkable class FlexInputSource(Protocol): diff --git a/flextool/engine_polars/_param_shapes.py b/flextool/engine_polars/_param_shapes.py index 890afff65..e44cdc7f2 100644 --- a/flextool/engine_polars/_param_shapes.py +++ b/flextool/engine_polars/_param_shapes.py @@ -421,6 +421,30 @@ class ParamAxes: } +# --------------------------------------------------------------------------- +# PERIOD_TIME_PARAMS — keys that admit a period/time Map shape +# --------------------------------------------------------------------------- +# +# Spec ``fix_map_reading.md`` §3.1. Every ``PARAM_ALLOWED_SHAPES`` key +# whose allow-list contains at least one of ``MAP_PERIOD`` / ``MAP_TIME`` +# / ``MAP_PERIOD_TIME`` — the 16 dual-axis keys PLUS the ~32 period-only +# keys ({SCALAR, MAP_PERIOD}). Period-only keys are included on purpose: +# classifying their rows is what lets the §3.3 per-variant allow-list +# check catch a stray ``Map(time)`` and raise (today silently dropped). +# +# Derived from the registry so the two cannot drift. Uses the three +# EXACT Shape enum members — NOT substring matching — so facet shapes +# such as ``MAP_PERIOD_TIER_FACET`` are correctly EXCLUDED. +_PERIOD_TIME_SHAPE_MEMBERS: "frozenset[Shape]" = frozenset( + (Shape.MAP_PERIOD, Shape.MAP_TIME, Shape.MAP_PERIOD_TIME) +) +PERIOD_TIME_PARAMS: "frozenset[tuple[str, str]]" = frozenset( + key + for key, shapes in PARAM_ALLOWED_SHAPES.items() + if _PERIOD_TIME_SHAPE_MEMBERS & set(shapes) +) + + # --------------------------------------------------------------------------- # ParamAxes — derived (map_levels, leaf) view per :class:`Shape` # --------------------------------------------------------------------------- @@ -955,6 +979,123 @@ def _entity_dim_columns_for_frame( return ["name"] +# --------------------------------------------------------------------------- +# Per-variant allow-list check (spec fix_map_reading.md §3.3) +# --------------------------------------------------------------------------- + + +# Dedupe the per-(class, param) INFO line to "one per run" (spec §3.3). +# resolve_param_shape may be called once per sub-solve; this keeps the log +# to a single line for a mixed-shape parameter across the whole process. +_MIXED_SHAPE_LOGGED: "set[tuple[str, str]]" = set() + + +def _variant_to_shape(variant: "tuple[str, ...]") -> "Shape | None": + """Map one classified axis path (e.g. ``("period",)``) to a Shape. + + Returns ``None`` for a path that does not correspond to a recognised + period/time shape (treated as a violation by the caller).""" + try: + return _shape_from_indices(list(variant)) + except _UnrecognisedIndex: + return None + + +def _entity_axes_map( + df: "pl.DataFrame", + ent_cols: "tuple[str, ...]", +) -> "dict[tuple[str, ...], tuple[str, ...]]": + """Group *df* rows by entity and return each entity's classified axis + path (``("period",)`` / ``("time",)`` / ``("period", "time")`` / ``()``) + derived from the non-null ``period`` / ``t`` columns. + + Used only on the error / mixed-shape-logging paths to name entities + and count per-shape multiplicities — never on the hot path. + """ + axis_cols = [c for c in ("period", "t") if c in df.columns] + ent_present = [c for c in ent_cols if c in df.columns] + out: "dict[tuple[str, ...], set[str]]" = {} + if not ent_present: + return {} + for row in df.select(ent_present + axis_cols).iter_rows(named=True): + ent = tuple(str(row[c]) for c in ent_present) + axes = out.setdefault(ent, set()) + if "period" in axis_cols and row.get("period") is not None: + axes.add("period") + if "t" in axis_cols and row.get("t") is not None: + axes.add("time") + canonical: "dict[tuple[str, ...], tuple[str, ...]]" = {} + for ent, axes in out.items(): + path: list[str] = [] + if "period" in axes: + path.append("period") + if "time" in axes: + path.append("time") + canonical[ent] = tuple(path) + return canonical + + +def _validate_shape_variants( + variants: "set[tuple[str, ...]]", + allowed: "set[Shape]", + df: "pl.DataFrame", + ent_cols: "tuple[str, ...]", + entity_class: str, + parameter_name: str, +) -> None: + """Check each per-row shape variant against *allowed* (spec §3.3). + + A combined ``MAP_PERIOD_TIME`` is accepted for a parameter whose + allow-list lacks it (e.g. ``commodity.price``) as long as every + individual row shape is allowed. A genuine 2-D row, or a time map + on a period-only parameter, is rejected with a + :class:`FlexToolConfigError` naming the offending entities and the + shape each was authored with. Logs one INFO line per (class, param) + per run when there is more than one variant. + """ + bad_variants = { + var for var in variants + if (_variant_to_shape(var) is None + or _variant_to_shape(var) not in allowed) + } + if bad_variants: + ent_axes = _entity_axes_map(df, ent_cols) + parts: list[str] = [] + for var in sorted(bad_variants, key=lambda t: "+".join(t)): + offenders = sorted( + "/".join(ent) for ent, path in ent_axes.items() + if path == var + ) + shape_name = ( + _variant_to_shape(var).value + if _variant_to_shape(var) is not None + else ("+".join(var) if var else "scalar") + ) + who = ", ".join(offenders[:10]) if offenders else "(unknown)" + parts.append(f"{shape_name} authored by {who}") + raise FlexToolConfigError( + f"Parameter ({entity_class!r}, {parameter_name!r}) has rows " + f"whose shape is not allowed: {'; '.join(parts)}. Allowed " + f"shapes: {_allowed_shape_names(allowed)}. Edit the source " + "database so every entity uses an allowed shape, or extend " + "PARAM_ALLOWED_SHAPES." + ) + key = (entity_class, parameter_name) + if len(variants) > 1 and key not in _MIXED_SHAPE_LOGGED: + _MIXED_SHAPE_LOGGED.add(key) + from collections import Counter + ent_axes = _entity_axes_map(df, ent_cols) + counts = Counter(ent_axes.values()) + rendered = ", ".join( + f"{('+'.join(path) if path else 'scalar')}: {n}" + for path, n in sorted(counts.items(), key=lambda kv: "+".join(kv[0])) + ) + _LOGGER.info( + "%s.%s: mixed shapes {%s} -> (d,t)", + entity_class, parameter_name, rendered, + ) + + def resolve_param_shape( source: "InputSource", entity_class: str, @@ -1106,7 +1247,32 @@ def resolve_param_shape( and Shape.SCALAR not in allowed): shape = Shape.SCALAR_STR - if shape not in allowed: + # Spec fix_map_reading.md §3.3 — per-variant allow-list check. When + # the source exposes ``parameter_shape_variants`` (SpineDbReader / + # InMemoryReader, for PERIOD_TIME_PARAMS keys), validate each + # per-row shape against ``allowed`` instead of the combined shape. + # So a mixed period+time frame (combined ``MAP_PERIOD_TIME``) is + # accepted for e.g. ``commodity.price`` — whose allow-list lacks + # ``MAP_PERIOD_TIME`` — as long as every individual row shape is + # allowed; a genuine 2-D row, or a time map on a period-only + # parameter, is still rejected. Falls back to the combined check + # when the source doesn't expose variants (test stubs) or returns an + # empty set (non-PERIOD_TIME keys). + variants: "set[tuple[str, ...]] | None" = None + variants_fn = getattr(source, "parameter_shape_variants", None) + if variants_fn is not None: + try: + raw_variants = variants_fn(entity_class, parameter_name) + except (KeyError, AttributeError): + raw_variants = None + if raw_variants: + variants = {tuple(v) for v in raw_variants} + if variants: + _validate_shape_variants( + variants, allowed, df, tuple(ent_cols), + entity_class, parameter_name, + ) + elif shape not in allowed: # Render observed shape for the message — labels can be empty # so reconstruct from the labels list. observed = (shape.value if shape is not None diff --git a/flextool/engine_polars/_spinedb_reader.py b/flextool/engine_polars/_spinedb_reader.py index 448cbf1ac..d31d4578c 100644 --- a/flextool/engine_polars/_spinedb_reader.py +++ b/flextool/engine_polars/_spinedb_reader.py @@ -18,12 +18,14 @@ """ from __future__ import annotations +import logging from collections import defaultdict from typing import Any import numpy as np import polars as pl +from flextool.engine_polars._solve_state import FlexToolConfigError from flextool.spinedb_backend._axis_enums import ( AxisContract, FlexDataIntegrityError, @@ -31,6 +33,16 @@ load_axis_contract, ) +_LOGGER = logging.getLogger(__name__) + +# Test seam (spec fix_map_reading.md §4 golden snapshot): when False, the +# per-row period/time placement (§3.1) is bypassed and PERIOD_TIME_PARAMS +# keys fall through to the legacy positional unroll. Always True in +# production; the golden snapshot test flips it to capture the legacy +# baseline and assert the new path is byte-identical for single-shape / +# non-registry parameters. +_PERIOD_TIME_PLACEMENT_ENABLED = True + # Late imports of spinedb_api at construction time keep the import # graph free of an unconditional dependency for users who only consume # CSVs. @@ -135,6 +147,9 @@ def __init__( if contract is None: contract = load_axis_contract() self._contract = contract + # Dedupe the "keys hit neither vocabulary" classification WARNING + # to one per (entity_class, parameter_name) (spec §3.1). + self._pt_classify_warned: set[tuple[str, str]] = set() # Build caches once. from spinedb_api import DatabaseMapping, from_database @@ -390,6 +405,29 @@ def parameter_shape_info(self, entity_class: str, f"unknown parameter ({entity_class!r}, {parameter_name!r})" ) rows = self._param_rows.get((cls_id, pdef["id"]), []) + + # Spec fix_map_reading.md §3.2 — for PERIOD_TIME_PARAMS keys return + # the canonical-order union of the per-row classified axes + # (``["period"]`` / ``["time"]`` / ``["period", "time"]``) via the + # shared classifier, so the resolver sees every row's semantics + # rather than just the deepest row's raw label. Falls back to the + # legacy deepest-wins path when classification is not possible + # (no enums + silent labels, etc.). + from flextool.engine_polars._param_shapes import PERIOD_TIME_PARAMS + if (entity_class, parameter_name) in PERIOD_TIME_PARAMS: + non_null = [(eid, v) for eid, v in rows if v is not None] + if non_null: + rows_axes = self._classify_pt_rows( + non_null, entity_class, parameter_name, + ) + if rows_axes is not None: + union: list[str | None] = [] + if any("period" in path for path in rows_axes): + union.append("period") + if any("time" in path for path in rows_axes): + union.append("time") + return union + # Probe the deepest-nested row to capture the widest schema. # Spine schema invariant: all rows for a parameter under a # scenario share the same shape, but we're defensive in case @@ -410,6 +448,53 @@ def parameter_shape_info(self, entity_class: str, return self._index_name_path(default) return [] + def parameter_shape_variants(self, entity_class: str, + parameter_name: str, + ) -> "set[tuple[str, ...]]": + """Return the distinct per-row classified axis paths for a + PERIOD_TIME_PARAMS parameter (spec fix_map_reading.md §3.2). + + Each element is a tuple of canonical axis labels + (``("period",)`` / ``("time",)`` / ``("period", "time")`` / + ``()`` for scalar). Returns an empty set for parameters that are + not PERIOD_TIME_PARAMS keys, or when classification is not + possible (no enums + silent labels) — in both cases the resolver + falls back to its combined-shape allow-list check. + """ + cls_id = self._class_name_to_id.get(entity_class) + if cls_id is None: + raise KeyError(f"unknown entity_class {entity_class!r}") + pdef = self._pdef_by_class_name.get((cls_id, parameter_name)) + if pdef is None: + raise KeyError( + f"unknown parameter ({entity_class!r}, {parameter_name!r})" + ) + from flextool.engine_polars._param_shapes import PERIOD_TIME_PARAMS + if (entity_class, parameter_name) not in PERIOD_TIME_PARAMS: + return set() + rows = self._param_rows.get((cls_id, pdef["id"]), []) + non_null = [(eid, v) for eid, v in rows if v is not None] + if not non_null: + # No explicit rows — the parameter resolves from its default. + default = pdef["default_value"] + if default is None: + return set() + axes = self._classify_pt_path( + default, + entity_class=entity_class, + entity_name="", + parameter_name=parameter_name, + ) + if any(a is None for a in axes): + return set() + return {tuple("time" if a == "t" else "period" for a in axes)} + rows_axes = self._classify_pt_rows( + non_null, entity_class, parameter_name, + ) + if rows_axes is None: + return set() + return set(rows_axes) + @staticmethod def _index_name_path(v: Any) -> "list[str | None]": """Walk *v*'s nesting and return raw ``Map.index_name`` labels @@ -807,29 +892,51 @@ def _unroll_rows( if not rows: return {}, [], None - # Discover index columns from the widest-shaped row (spec - # §5.2.5). In practice flextool's params don't mix shapes - # within one scenario — but we're defensive. + columns: dict[str, list] | None = None index_cols: list[str] = [] - for _eid, v in rows: - cand = self._discover_index_cols(v, parameter_name) - if len(cand) > len(index_cols): - index_cols = cand - - # Pre-allocate one list per output column. - col_names = ent_cols + index_cols + ["value"] - columns: dict[str, list] = {name: [] for name in col_names} - - # Walk each (entity_id, parsed_value) pair. ``idx_path`` is a - # positional list mirroring ``index_cols`` — mutated via - # append/pop inside the recursion, no per-node dict copy. - idx_path: list[Any] = [] - for eid, v in rows: - cls_id, ent_name = self._entity_by_id[eid] - ent_values = self._entity_dim_values(cls_id, ent_name) - self._unroll_value( - v, index_cols, columns, ent_cols, ent_values, idx_path, + + # Spec fix_map_reading.md §3.1 — per-row period/time placement for + # the dual-axis + period-only registry parameters. Each Map level + # of each row is classified as ``period`` or ``t`` (by index_name, + # or by value domain against the global axis vocabulary for silent + # labels) and written into its OWN canonical column — so a + # ``Map(period)`` row and a ``Map(time)`` row of the same + # parameter no longer collide by position. Returns ``None`` when + # nothing can be classified (no enums + silent labels, etc.); the + # legacy positional path below then runs, byte-for-byte as before. + from flextool.engine_polars._param_shapes import PERIOD_TIME_PARAMS + if (_PERIOD_TIME_PLACEMENT_ENABLED + and (entity_class, parameter_name) in PERIOD_TIME_PARAMS): + pt = self._unroll_period_time_rows( + rows, ent_cols, parameter_name, entity_class, ) + if pt is not None: + columns, index_cols = pt + + if columns is None: + # Discover index columns from the widest-shaped row (spec + # §5.2.5). In practice flextool's params don't mix shapes + # within one scenario — but we're defensive. + index_cols = [] + for _eid, v in rows: + cand = self._discover_index_cols(v, parameter_name) + if len(cand) > len(index_cols): + index_cols = cand + + # Pre-allocate one list per output column. + col_names = ent_cols + index_cols + ["value"] + columns = {name: [] for name in col_names} + + # Walk each (entity_id, parsed_value) pair. ``idx_path`` is a + # positional list mirroring ``index_cols`` — mutated via + # append/pop inside the recursion, no per-node dict copy. + idx_path: list[Any] = [] + for eid, v in rows: + cls_id, ent_name = self._entity_by_id[eid] + ent_values = self._entity_dim_values(cls_id, ent_name) + self._unroll_value( + v, index_cols, columns, ent_cols, ent_values, idx_path, + ) # Apply the contract dtype only to parameters that contain at # least one Spine Map row. Map is the only shape where Spine @@ -1149,6 +1256,298 @@ def _emit_leaf(self, columns: dict[str, list], columns[col].append(v) columns["value"].append(value) + # ------------------------------------------------------------------ + # Per-row period/time placement (spec fix_map_reading.md §3.1 / §3.2) + + def _classify_pt_level( + self, + index_name: "str | None", + keys: "Any", + *, + entity_class: str, + entity_name: str, + parameter_name: str, + ) -> "str | None": + """Classify ONE Map/TimeSeries level as ``"period"`` or ``"t"``. + + Shared by the reader's per-row placement (§3.1) and by + :meth:`parameter_shape_info` / :meth:`parameter_shape_variants` + (§3.2) so the two cannot diverge. + + Returns ``"period"`` / ``"t"`` for a classified level, or ``None`` + to signal "keep today's positional placement" (silent label with + no enums on the reader, or silent-label keys that match neither + vocabulary — in the latter case a single WARNING is emitted per + (class, param) and the keys are dropped downstream by the + scenario trim). + + Raises :class:`FlexDataIntegrityError` when a silent-label level's + keys match BOTH the period and timestep vocabularies (ambiguous), + and :class:`FlexToolConfigError` when an explicit label is not a + valid axis for a period/time parameter. + """ + from flextool.engine_polars._param_shapes import _normalise_label + norm = _normalise_label(index_name) + if norm == "period": + return "period" + if norm in ("time", "t"): + return "t" + if norm is None: + # Silent label — classify by value domain against the reader's + # GLOBAL axis vocabulary (every period / timestep in the + # model, not just the active solve's dt). + d_enum = self._axis_enums.get("d") if self._axis_enums else None + t_enum = self._axis_enums.get("t") if self._axis_enums else None + if d_enum is None and t_enum is None: + # No enums on the reader (unit-test setups) — place by + # position, as today. + return None + d_vocab = (set(d_enum.categories.to_list()) + if d_enum is not None else set()) + t_vocab = (set(t_enum.categories.to_list()) + if t_enum is not None else set()) + key_set = {str(k) for k in keys if k is not None} + hits_d = bool(key_set & d_vocab) + hits_t = bool(key_set & t_vocab) + if hits_d and hits_t: + raise FlexDataIntegrityError( + f"Parameter {entity_class}.{parameter_name}: entity " + f"{entity_name!r} has a silent-label Map whose keys " + f"match BOTH the period and the timestep vocabulary; " + f"cannot classify the axis. Author the Map's " + f"index_name as 'period' or 'time'." + ) + if hits_d: + return "period" + if hits_t: + return "t" + # Keys hit neither vocabulary — keep today's positional + # placement; the scenario trim drops them as before. + wkey = (entity_class, parameter_name) + if wkey not in self._pt_classify_warned: + self._pt_classify_warned.add(wkey) + _LOGGER.warning( + "%s.%s: silent-label Map keys match neither the period " + "nor the timestep vocabulary for entity %r; rows kept " + "positionally and trimmed by the scenario filter.", + entity_class, parameter_name, entity_name, + ) + return None + # Any other explicit label is not a valid axis for these params. + raise FlexToolConfigError( + f"Parameter ({entity_class!r}, {parameter_name!r}): entity " + f"{entity_name!r} has a Map index_name {index_name!r} which is " + f"not a valid axis for a period/time parameter (expected " + f"'period' or 'time')." + ) + + def _classify_pt_path( + self, + v: Any, + *, + entity_class: str, + entity_name: str, + parameter_name: str, + ) -> "list[str | None]": + """Classify every Map/TimeSeries level of one row value. + + Returns an ordered list of ``"period"`` / ``"t"`` / ``None`` (one + per nesting level; empty for a scalar). ``None`` means "keep + positional placement" (see :meth:`_classify_pt_level`).""" + from spinedb_api.parameter_value import Map, TimeSeries, Array + out: list[str | None] = [] + cur = v + while True: + if isinstance(cur, Map): + out.append(self._classify_pt_level( + cur.index_name, cur.indexes, + entity_class=entity_class, + entity_name=entity_name, + parameter_name=parameter_name, + )) + if len(cur.values) == 0: + break + cur = cur.values[0] + continue + if isinstance(cur, TimeSeries): + out.append("t") + break + if isinstance(cur, Array): + # Array keys are positional ints — out of scope for the + # period/time classifier; keep positional placement. + out.append(None) + break + break + return out + + def _classify_pt_rows( + self, + rows: list[tuple[int, Any]], + entity_class: str, + parameter_name: str, + ) -> "list[tuple[str, ...]] | None": + """Classify every row into a canonical axis path tuple. + + Each tuple uses the canonical labels ``"period"`` / ``"time"`` + (matching :meth:`parameter_shape_info`'s raw-label output) in + canonical order (period before time). Returns ``None`` when ANY + level is unclassifiable (silent label with no enums, or keys that + match neither vocabulary) — the caller then falls back to the + legacy behaviour. Shared by :meth:`parameter_shape_info` and + :meth:`parameter_shape_variants`.""" + out: list[tuple[str, ...]] = [] + for eid, v in rows: + _cls_id, ent_name = self._entity_by_id[eid] + axes = self._classify_pt_path( + v, + entity_class=entity_class, + entity_name=ent_name, + parameter_name=parameter_name, + ) + if any(a is None for a in axes): + return None + path = tuple("time" if a == "t" else "period" for a in axes) + out.append(path) + return out + + def _unroll_period_time_rows( + self, + rows: list[tuple[int, Any]], + ent_cols: list[str], + parameter_name: str, + entity_class: str, + ) -> "tuple[dict[str, list], list[str]] | None": + """Build the columnar dict for a PERIOD_TIME_PARAMS parameter with + per-row axis placement (spec §3.1). + + Returns ``(columns, index_cols)`` where ``index_cols`` is the + canonical axis column list (``period`` before ``t``, only axes + that occur in some row). Returns ``None`` to signal "fall back to + the legacy positional unroll" when nothing can be classified (so + the caller rebuilds via the unchanged legacy path). + """ + # Pass 1 — classify each row; collect which canonical axes appear. + classified: list[tuple[int, Any, list[str | None]]] = [] + any_period = False + any_t = False + for eid, v in rows: + _cls_id, ent_name = self._entity_by_id[eid] + axes = self._classify_pt_path( + v, + entity_class=entity_class, + entity_name=ent_name, + parameter_name=parameter_name, + ) + concrete = [a for a in axes if a is not None] + if len(concrete) != len(set(concrete)): + raise FlexDataIntegrityError( + f"Parameter {entity_class}.{parameter_name}: entity " + f"{ent_name!r} maps two Map levels to the same axis " + f"({concrete}); each axis may appear at most once." + ) + classified.append((eid, v, axes)) + for a in axes: + if a == "period": + any_period = True + elif a == "t": + any_t = True + + canonical: list[str] = [] + if any_period: + canonical.append("period") + if any_t: + canonical.append("t") + if not canonical: + # Nothing classified (e.g. no enums + silent labels, or only + # scalar/Array rows) — fall back to the legacy path. + return None + + columns: dict[str, list] = { + name: [] for name in (ent_cols + canonical + ["value"]) + } + for eid, v, axes in classified: + cls_id, ent_name = self._entity_by_id[eid] + ent_values = self._entity_dim_values(cls_id, ent_name) + # Resolve each level to a concrete canonical column. A + # classified level maps directly; an unclassified level keeps + # positional placement into the canonical column at its depth. + row_cols: list[str] = [] + for i, a in enumerate(axes): + if a == "period": + row_cols.append("period") + elif a == "t": + row_cols.append("t") + else: + row_cols.append( + canonical[i] if i < len(canonical) else canonical[-1] + ) + if len(row_cols) != len(set(row_cols)): + # Positional fallback collided (pathological depth-2 + # unclassified row with a 1-axis canonical set) — bail to + # the legacy path for the whole parameter. + return None + self._emit_pt_value( + v, row_cols, 0, {}, columns, ent_cols, ent_values, canonical, + ) + return columns, canonical + + def _emit_pt_value( + self, + v: Any, + row_cols: list[str], + level: int, + pending: dict[str, Any], + columns: dict[str, list], + ent_cols: list[str], + ent_values: list[str], + canonical: list[str], + ) -> None: + """Recursively unroll *v* into ``columns`` using the per-row axis + column mapping *row_cols* (spec §3.1). + + ``pending`` maps a canonical column name to the current key at + that depth; canonical columns not populated by this row's path are + written as ``None``. + """ + from spinedb_api.parameter_value import Map, TimeSeries, Array + if not isinstance(v, (Map, TimeSeries, Array)): + for col, val in zip(ent_cols, ent_values): + columns[col].append(val) + for col in canonical: + columns[col].append(pending.get(col)) + columns["value"].append(_coerce_value(v)) + return + col = row_cols[level] + if isinstance(v, Map): + for idx, child in zip(v.indexes, v.values): + pending[col] = _coerce_index(idx) + self._emit_pt_value( + child, row_cols, level + 1, pending, + columns, ent_cols, ent_values, canonical, + ) + pending.pop(col, None) + return + if isinstance(v, TimeSeries): + for idx, val in zip(v.indexes, v.values): + pending[col] = _coerce_index(idx) + for c, ev in zip(ent_cols, ent_values): + columns[c].append(ev) + for c in canonical: + columns[c].append(pending.get(c)) + columns["value"].append(_coerce_value(val)) + pending.pop(col, None) + return + if isinstance(v, Array): + for i, val in enumerate(v.values): + pending[col] = i + for c, ev in zip(ent_cols, ent_values): + columns[c].append(ev) + for c in canonical: + columns[c].append(pending.get(c)) + columns["value"].append(_coerce_value(val)) + pending.pop(col, None) + return + # ------------------------------------------------------------------ # Materialisation diff --git a/tests/engine_polars/test_mixed_shape_dual_axis.py b/tests/engine_polars/test_mixed_shape_dual_axis.py new file mode 100644 index 000000000..827a66528 --- /dev/null +++ b/tests/engine_polars/test_mixed_shape_dual_axis.py @@ -0,0 +1,493 @@ +"""Mixed period/time Map shapes within one parameter (spec fix_map_reading.md). + +Builds a real Spine DB in ``tmp_path`` per case and exercises +:class:`SpineDbReader` per-row period/time placement (§3.1), +``parameter_shape_info`` / ``parameter_shape_variants`` (§3.2) and the +resolver's per-variant allow-list check (§3.3). + +Every case is parametrised over **row order × reader mode** — "without +enums" (unit-test setup, label-only classification) and "with enums" +(production path: ``axis_enums`` + ``contract`` + ``scenario`` so +silent-label value-domain classification + scenario trim are active). + +Implements the 13 cases of spec §4. +""" +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +import polars as pl +import pytest + +from flextool.engine_polars import SpineDbReader +from flextool.engine_polars._axis_enums import ( + reset_global_axis_enums, + set_global_axis_enums, +) +from flextool.engine_polars._param_shapes import ( + Shape, + broadcast_to_period_time, + resolve_param_shape, +) +from flextool.engine_polars._solve_state import FlexToolConfigError +from flextool.spinedb_backend._axis_enums import FlexDataIntegrityError + +PERIODS = ["p2025", "p2030"] +TIMESTEPS = ["t01", "t02"] + +ORDERS = ["as_is", "reversed"] +MODES = ["no_enums", "with_enums"] + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _m(keys, values, index_name=None): + """Build a spinedb_api Map with an optional explicit index_name.""" + from spinedb_api.parameter_value import Map + if index_name is None: + return Map(list(keys), list(values)) + return Map(list(keys), list(values), index_name=index_name) + + +def _ts(stamps, values): + """Build a spinedb_api TimeSeries (variable-resolution).""" + from spinedb_api.parameter_value import TimeSeriesVariableResolution + return TimeSeriesVariableResolution(list(stamps), list(values), False, False) + + +def _build_db( + path: Path, + class_name: str, + param: str, + rows: list[tuple[str, object]], + *, + order: str = "as_is", +) -> str: + """Build a one-class / one-parameter Spine DB; return the sqlite URL.""" + from spinedb_api import DatabaseMapping + from spinedb_api.parameter_value import to_database + + if order == "reversed": + rows = list(reversed(rows)) + url = f"sqlite:///{path}" + with DatabaseMapping(url, create=True) as db: + db.add_entity_class(name=class_name) + db.add_parameter_definition(entity_class_name=class_name, name=param) + db.add_scenario(name="s") + db.add_scenario_alternative( + scenario_name="s", alternative_name="Base", rank=1, + ) + for ent, _v in rows: + db.add_entity(entity_class_name=class_name, name=ent) + for ent, v in rows: + val, typ = to_database(v) + db.add_parameter_value( + entity_class_name=class_name, entity_byname=(ent,), + parameter_definition_name=param, alternative_name="Base", + value=val, type=typ, + ) + db.commit_session("x") + return url + + +def _reader(url, mode, *, periods=PERIODS, timesteps=TIMESTEPS): + """Return (reader, enums) for the requested mode.""" + if mode == "with_enums": + enums = {"d": pl.Enum(periods), "t": pl.Enum(timesteps)} + return SpineDbReader(url, "s", axis_enums=enums), enums + return SpineDbReader(url, "s"), None + + +def _dt(enums, *, periods=PERIODS, timesteps=TIMESTEPS): + """Build a dense (d, t) period_filter frame covering periods×timesteps.""" + d_col, t_col = [], [] + for p in periods: + for t in timesteps: + d_col.append(p) + t_col.append(t) + if enums is not None: + return pl.DataFrame({ + "d": pl.Series(d_col, dtype=enums["d"]), + "t": pl.Series(t_col, dtype=enums["t"]), + }) + return pl.DataFrame({"d": d_col, "t": t_col}) + + +@contextmanager +def _maybe_global(enums): + """Activate the cascade-wide axis enums when running the enum mode.""" + if enums is None: + yield + return + tok = set_global_axis_enums(enums) + try: + yield + finally: + reset_global_axis_enums(tok) + + +def _broadcast_rows(reader, enums, class_name, param, entity_alias="group", + *, periods=PERIODS, timesteps=TIMESTEPS): + """Resolve + broadcast a parameter to a materialised (e, d, t) frame.""" + dt = _dt(enums, periods=periods, timesteps=timesteps) + with _maybe_global(enums): + res = resolve_param_shape(reader, class_name, param, period_filter=dt) + param_obj = broadcast_to_period_time(res, entity_alias, dt) + mat = (param_obj.lazy.collect() + if param_obj is not None else None) + return res, mat + + +# --------------------------------------------------------------------------- +# Case 1 — labelled period map + labelled time map → 8 rows, order-stable +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case01_labelled_period_plus_time(tmp_path, order, mode): + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_period", _m(PERIODS, [50.0, 60.0], "period")), + ("fg_time", _m(TIMESTEPS, [10.0, 20.0], "time"))], + order=order, + ) + reader, enums = _reader(url, mode) + res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + assert res is not None and res.shape is Shape.MAP_PERIOD_TIME + assert mat.height == 8 + # fg_period: constant per period across both timesteps. + fp = mat.filter(pl.col("group") == "fg_period").sort("d", "t") + assert fp.get_column("value").to_list() == [50.0, 50.0, 60.0, 60.0] + # fg_time: constant per timestep across both periods. + ft = mat.filter(pl.col("group") == "fg_time").sort("d", "t") + assert ft.get_column("value").to_list() == [10.0, 20.0, 10.0, 20.0] + + +# --------------------------------------------------------------------------- +# Case 2 — scalar + period map + time map + 2-D map on four entities +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case02_four_shapes(tmp_path, order, mode): + two_d = _m(PERIODS, [_m(TIMESTEPS, [1.0, 2.0], "time"), + _m(TIMESTEPS, [3.0, 4.0], "time")], "period") + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_scalar", 7.0), + ("fg_period", _m(PERIODS, [50.0, 60.0], "period")), + ("fg_time", _m(TIMESTEPS, [10.0, 20.0], "time")), + ("fg_2d", two_d)], + order=order, + ) + reader, enums = _reader(url, mode) + res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + assert res is not None and res.shape is Shape.MAP_PERIOD_TIME + # Each entity fully covers the 2×2 grid = 4 rows ⇒ 16 total. + assert mat.height == 16 + assert set(mat.get_column("group").to_list()) == { + "fg_scalar", "fg_period", "fg_time", "fg_2d"} + sc = mat.filter(pl.col("group") == "fg_scalar") + assert sc.get_column("value").to_list() == [7.0, 7.0, 7.0, 7.0] + d2 = mat.filter(pl.col("group") == "fg_2d").sort("d", "t") + assert d2.get_column("value").to_list() == [1.0, 2.0, 3.0, 4.0] + + +# --------------------------------------------------------------------------- +# Case 3 — 2-D Map(period,time) + 1-D Map(time): the time row lands in t +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case03_2d_plus_1d_time(tmp_path, order, mode): + two_d = _m(PERIODS, [_m(TIMESTEPS, [1.0, 2.0], "time"), + _m(TIMESTEPS, [3.0, 4.0], "time")], "period") + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_2d", two_d), + ("fg_time", _m(TIMESTEPS, [10.0, 20.0], "time"))], + order=order, + ) + reader, enums = _reader(url, mode) + # The explicit (placed) frame: the time row has null period. + expl = reader.parameter_explicit("flowGroup", "min_instant_flow") + ft = expl.filter(pl.col("name") == "fg_time") + assert ft.get_column("period").to_list() == [None, None] + assert [str(x) for x in ft.get_column("t").to_list()] == TIMESTEPS + res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + assert res.shape is Shape.MAP_PERIOD_TIME + ft_b = mat.filter(pl.col("group") == "fg_time").sort("d", "t") + assert ft_b.get_column("value").to_list() == [10.0, 20.0, 10.0, 20.0] + + +# --------------------------------------------------------------------------- +# Case 4 — silent "x" period map + silent "x" time map +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case04_silent_period_plus_time(tmp_path, order, mode): + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_period", _m(PERIODS, [50.0, 60.0])), # silent index_name + ("fg_time", _m(TIMESTEPS, [10.0, 20.0]))], + order=order, + ) + reader, enums = _reader(url, mode) + if mode == "with_enums": + res, mat = _broadcast_rows( + reader, enums, "flowGroup", "min_instant_flow") + assert res.shape is Shape.MAP_PERIOD_TIME + assert mat.height == 8 + fp = mat.filter(pl.col("group") == "fg_period").sort("d", "t") + assert fp.get_column("value").to_list() == [50.0, 50.0, 60.0, 60.0] + else: + # Without enums the behaviour is as today (documented): the + # silent labels cannot be value-domain classified, so the reader + # falls back to the legacy positional path. We only assert it + # does not crash. + reader.parameter_explicit("flowGroup", "min_instant_flow") + + +# --------------------------------------------------------------------------- +# Case 5 — silent "x" time map next to a labelled period map (enums) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +def test_case05_silent_time_plus_labelled_period(tmp_path, order): + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_period", _m(PERIODS, [50.0, 60.0], "period")), + ("fg_time", _m(TIMESTEPS, [10.0, 20.0]))], # silent, timestep keys + order=order, + ) + reader, enums = _reader(url, "with_enums") + res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + assert res.shape is Shape.MAP_PERIOD_TIME + assert mat.height == 8 + ft = mat.filter(pl.col("group") == "fg_time").sort("d", "t") + assert ft.get_column("value").to_list() == [10.0, 20.0, 10.0, 20.0] + + +# --------------------------------------------------------------------------- +# Case 6 — silent 1-D row next to a labelled 2-D row (works today) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case06_silent_1d_plus_labelled_2d(tmp_path, order, mode): + two_d = _m(PERIODS, [_m(TIMESTEPS, [1.0, 2.0], "time"), + _m(TIMESTEPS, [3.0, 4.0], "time")], "period") + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_2d", two_d), + ("fg_period", _m(PERIODS, [50.0, 60.0], "period"))], + order=order, + ) + reader, enums = _reader(url, mode) + res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + assert res.shape is Shape.MAP_PERIOD_TIME + assert mat.height == 8 + fp = mat.filter(pl.col("group") == "fg_period").sort("d", "t") + assert fp.get_column("value").to_list() == [50.0, 50.0, 60.0, 60.0] + + +# --------------------------------------------------------------------------- +# Case 7 — commodity.price mixed period/time accepted; real 2-D rejected +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case07_commodity_price_mixed_accepted(tmp_path, order, mode): + url = _build_db( + tmp_path / "db.sqlite", "commodity", "price", + [("c_period", _m(PERIODS, [50.0, 60.0], "period")), + ("c_time", _m(TIMESTEPS, [10.0, 20.0], "time"))], + order=order, + ) + reader, enums = _reader(url, mode) + res, mat = _broadcast_rows(reader, enums, "commodity", "price", + entity_alias="commodity") + # Combined shape is MAP_PERIOD_TIME (not in commodity.price's allow-list) + # yet accepted because every per-row variant is allowed. + assert res.shape is Shape.MAP_PERIOD_TIME + assert mat.height == 8 + + +@pytest.mark.parametrize("mode", MODES) +def test_case07_commodity_price_real_2d_rejected(tmp_path, mode): + two_d = _m(PERIODS, [_m(TIMESTEPS, [1.0, 2.0], "time"), + _m(TIMESTEPS, [3.0, 4.0], "time")], "period") + url = _build_db( + tmp_path / "db.sqlite", "commodity", "price", + [("c_2d", two_d)], + ) + reader, enums = _reader(url, mode) + dt = _dt(enums) + with _maybe_global(enums): + with pytest.raises(FlexToolConfigError): + resolve_param_shape(reader, "commodity", "price", period_filter=dt) + + +# --------------------------------------------------------------------------- +# Case 8 — keys hit BOTH vocabularies → FlexDataIntegrityError +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +def test_case08_keys_hit_both_vocabs(tmp_path, order): + # "shared" appears in BOTH the period and timestep vocabulary. + periods = ["shared", "p2030"] + timesteps = ["shared", "t02"] + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_ambig", _m(["shared"], [5.0]))], # silent label, ambiguous key + order=order, + ) + reader, _enums = _reader(url, "with_enums", + periods=periods, timesteps=timesteps) + with pytest.raises(FlexDataIntegrityError): + reader.parameter_explicit("flowGroup", "min_instant_flow") + + +# --------------------------------------------------------------------------- +# Case 9 — multi-solve: out-of-active-solve period keys classify stably +# --------------------------------------------------------------------------- + + +def test_case09_multisolve_stable_dims(tmp_path): + # Global vocabulary spans three periods; entity authors p2040 only. + periods = ["p2025", "p2030", "p2040"] + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_future", _m(["p2040"], [99.0], "period")), + ("fg_now", _m(["p2025", "p2030"], [1.0, 2.0], "period"))], + ) + reader, enums = _reader(url, "with_enums", periods=periods) + # Sub-solve A: active periods {p2025, p2030} (fg_future outside). + dtA = _dt(enums, periods=["p2025", "p2030"]) + # Sub-solve B: active periods {p2040}. + dtB = _dt(enums, periods=["p2040"]) + with _maybe_global(enums): + resA = resolve_param_shape(reader, "flowGroup", "min_instant_flow", + period_filter=dtA) + resB = resolve_param_shape(reader, "flowGroup", "min_instant_flow", + period_filter=dtB) + pA = broadcast_to_period_time(resA, "group", dtA) + pB = broadcast_to_period_time(resB, "group", dtB) + assert resA.shape is resB.shape is Shape.MAP_PERIOD + # Param dims stable across sub-solves (does not raise on out-of-solve). + assert pA.dims == pB.dims == ("group", "d") + + +# --------------------------------------------------------------------------- +# Case 10 — TimeSeries row mixed with a Map(period) row +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("order", ORDERS) +@pytest.mark.parametrize("mode", MODES) +def test_case10_timeseries_plus_period(tmp_path, order, mode): + ts = _ts(["2025-01-01T00:00:00", "2025-01-01T01:00:00"], [10.0, 20.0]) + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_ts", ts), + ("fg_period", _m(PERIODS, [50.0, 60.0], "period"))], + order=order, + ) + reader, _enums = _reader(url, mode) + # The TimeSeries level classifies to the t axis regardless of enums + # (classification of a TimeSeries level is label-free). + assert reader.parameter_shape_info( + "flowGroup", "min_instant_flow") == ["period", "time"] + assert reader.parameter_shape_variants("flowGroup", "min_instant_flow") == { + ("period",), ("time",)} + if mode == "no_enums": + # Without the scenario trim, the placed frame shows the TimeSeries + # row in the t column with a null period. + expl = reader.parameter_explicit("flowGroup", "min_instant_flow") + fts = expl.filter(pl.col("name") == "fg_ts") + assert fts.get_column("period").to_list() == [None, None] + assert fts.get_column("t").null_count() == 0 + + +# --------------------------------------------------------------------------- +# Case 11 — null-split fix: inactive tokens are not broadcast (no dup keys) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", MODES) +def test_case11_inactive_token_not_broadcast(tmp_path, mode): + # fg_time authors an out-of-vocabulary / inactive timestep t99. + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_time", _m(["t01", "t99"], [10.0, 20.0], "time")), + ("fg_period", _m(PERIODS, [50.0, 60.0], "period"))], + ) + reader, enums = _reader(url, mode) + _res, mat = _broadcast_rows(reader, enums, "flowGroup", "min_instant_flow") + # No duplicate (group, d, t) keys produced by a broadcast of t99. + assert mat.height == mat.select("group", "d", "t").unique().height + ft = mat.filter(pl.col("group") == "fg_time") + # t99 is inactive ⇒ only the t01 column survives (one per period). + assert set(str(x) for x in ft.get_column("t").to_list()) == {"t01"} + + +# --------------------------------------------------------------------------- +# Case 12 — period-only parameter with a time map → FlexToolConfigError +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", MODES) +def test_case12_period_only_explicit_time_map_rejected(tmp_path, mode): + url = _build_db( + tmp_path / "db.sqlite", "group", "co2_max_period", + [("g_time", _m(TIMESTEPS, [10.0, 20.0], "time"))], + ) + reader, enums = _reader(url, mode) + dt = _dt(enums) + with _maybe_global(enums): + with pytest.raises(FlexToolConfigError): + resolve_param_shape(reader, "group", "co2_max_period", + period_filter=dt) + + +def test_case12_period_only_silent_time_keys_rejected(tmp_path): + # Silent index_name but keys are timesteps → classified t → rejected. + url = _build_db( + tmp_path / "db.sqlite", "group", "co2_max_period", + [("g_silent_time", _m(TIMESTEPS, [10.0, 20.0]))], + ) + reader, enums = _reader(url, "with_enums") + dt = _dt(enums) + with _maybe_global(enums): + with pytest.raises(FlexToolConfigError): + resolve_param_shape(reader, "group", "co2_max_period", + period_filter=dt) + + +# --------------------------------------------------------------------------- +# Case 13 — explicit non-axis label → FlexToolConfigError +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", MODES) +def test_case13_explicit_non_axis_label_rejected(tmp_path, mode): + url = _build_db( + tmp_path / "db.sqlite", "flowGroup", "min_instant_flow", + [("fg_branch", _m(["b1", "b2"], [1.0, 2.0], "branch"))], + ) + reader, _enums = _reader(url, mode) + with pytest.raises(FlexToolConfigError): + reader.parameter_explicit("flowGroup", "min_instant_flow") diff --git a/tests/engine_polars/test_mixed_shape_golden.py b/tests/engine_polars/test_mixed_shape_golden.py new file mode 100644 index 000000000..023ceb5aa --- /dev/null +++ b/tests/engine_polars/test_mixed_shape_golden.py @@ -0,0 +1,88 @@ +"""Permanent golden snapshot (spec fix_map_reading.md §4). + +Guards the "unchanged by construction" claim of the per-row period/time +placement: for every parameter OUTSIDE ``PERIOD_TIME_PARAMS``, plus every +``PERIOD_TIME_PARAMS`` parameter whose rows are all one shape, the +``parameter()`` / ``parameter_explicit()`` frames must be byte-identical +whether or not the new placement path runs. + +The baseline is produced by the ``_PERIOD_TIME_PLACEMENT_ENABLED`` test +seam (toggled to ``False`` the legacy positional unroll runs). Comparing +the new path against the legacy path over the real fixture corpus (built +from JSON per invariant #3 — no checked-in ``.sqlite``) proves the hot +path is unchanged for single-shape / non-registry parameters. +""" +from __future__ import annotations + +import pytest + +import flextool.engine_polars._spinedb_reader as sr +from flextool.engine_polars import SpineDbReader + +# Corpus entries: (db_url fixture name, scenario). Built from the shared +# session JSON-backed DB fixtures in tests/conftest.py. +_CORPUS = [ + ("test_db_url", "base"), + ("test_db_url", "coal_co2_price"), + ("test_db_url", "fullYear"), + ("test_db_url", "multi_year"), + ("lh2_db_url", "lh2_three_region"), +] + + +def _enumerate_params(reader: SpineDbReader): + """Yield every (entity_class, parameter_name) the reader knows.""" + for (cls_id, pname) in reader._pdef_by_class_name: + cls_name = reader._class_id_to_name.get(cls_id) + if cls_name is None: + continue + yield cls_name, pname + + +def _capture(reader, cls_name, pname): + return ( + reader.parameter(cls_name, pname), + reader.parameter_explicit(cls_name, pname), + ) + + +@pytest.mark.parametrize("db_fixture_name,scenario", _CORPUS) +def test_golden_single_shape_unchanged(request, db_fixture_name, scenario): + url = request.getfixturevalue(db_fixture_name) + # Build WITHOUT enums so we compare the raw columnar unroll output of + # the new placement path against the legacy positional path, isolating + # the _unroll_rows change from the cast-on-emit layer. + reader = SpineDbReader(url, scenario) + + compared = 0 + skipped_mixed: list[tuple[str, str]] = [] + original = sr._PERIOD_TIME_PLACEMENT_ENABLED + try: + for cls_name, pname in _enumerate_params(reader): + # Eligibility: non-PERIOD_TIME (variants == empty set) or a + # PERIOD_TIME param with at most one classified shape variant. + try: + variants = reader.parameter_shape_variants(cls_name, pname) + except KeyError: + continue + if len(variants) > 1: + skipped_mixed.append((cls_name, pname)) + continue + + sr._PERIOD_TIME_PLACEMENT_ENABLED = True + new_p, new_pe = _capture(reader, cls_name, pname) + sr._PERIOD_TIME_PLACEMENT_ENABLED = False + old_p, old_pe = _capture(reader, cls_name, pname) + + assert new_p.equals(old_p), ( + f"parameter({cls_name!r}, {pname!r}) changed by per-row " + f"placement (byte-parity violation)") + assert new_pe.equals(old_pe), ( + f"parameter_explicit({cls_name!r}, {pname!r}) changed by " + f"per-row placement (byte-parity violation)") + compared += 1 + finally: + sr._PERIOD_TIME_PLACEMENT_ENABLED = original + + # Sanity: the corpus actually exercised the comparison. + assert compared > 0 diff --git a/tests/engine_polars/test_period_time_params_registry.py b/tests/engine_polars/test_period_time_params_registry.py new file mode 100644 index 000000000..b74f9b766 --- /dev/null +++ b/tests/engine_polars/test_period_time_params_registry.py @@ -0,0 +1,91 @@ +"""Pin test for ``PERIOD_TIME_PARAMS`` (spec fix_map_reading.md §3.1 / STEP 1). + +``PERIOD_TIME_PARAMS`` is derived from ``PARAM_ALLOWED_SHAPES`` so the two +cannot drift. This test pins the derivation RULE, the exact count, and +the membership — in the style of +``tests/engine_polars/autoscale/test_registry_coverage.py`` — so a future +registry edit that silently changes which parameters get per-row +period/time placement fails loudly. +""" +from __future__ import annotations + +from flextool.engine_polars._param_shapes import ( + PARAM_ALLOWED_SHAPES, + PERIOD_TIME_PARAMS, + Shape, +) + +# The three EXACT Shape members that define membership (NOT substring +# matching — e.g. MAP_PERIOD_TIER_FACET must be EXCLUDED). +_PERIOD_TIME_SHAPES = {Shape.MAP_PERIOD, Shape.MAP_TIME, Shape.MAP_PERIOD_TIME} + + +def test_derivation_rule_matches_registry(): + expected = { + key for key, shapes in PARAM_ALLOWED_SHAPES.items() + if _PERIOD_TIME_SHAPES & set(shapes) + } + assert set(PERIOD_TIME_PARAMS) == expected + + +def test_count_is_48_split_16_dual_32_period_only(): + assert len(PERIOD_TIME_PARAMS) == 48 + # 16 dual-axis: 15 with the full 2-D shape + commodity.price (two 1-D). + dual_2d = {k for k, v in PARAM_ALLOWED_SHAPES.items() + if Shape.MAP_PERIOD_TIME in v} + assert len(dual_2d) == 15 + commodity_price = {k for k, v in PARAM_ALLOWED_SHAPES.items() + if Shape.MAP_TIME in v and Shape.MAP_PERIOD_TIME not in v} + assert commodity_price == {("commodity", "price")} + # 32 period-only: exactly {SCALAR, MAP_PERIOD}. + period_only = {k for k, v in PARAM_ALLOWED_SHAPES.items() + if set(v) == {Shape.SCALAR, Shape.MAP_PERIOD}} + assert len(period_only) == 32 + assert set(PERIOD_TIME_PARAMS) == dual_2d | commodity_price | period_only + + +def test_facet_and_scalar_str_shapes_excluded(): + # Facet (price-ladder) + scalar-string (invest_method) keys must NOT + # be routed through per-row period/time placement. + excluded = set(PARAM_ALLOWED_SHAPES) - set(PERIOD_TIME_PARAMS) + assert excluded == { + ("commodity", "price_ladder_cumulative"), + ("commodity", "price_ladder_annual"), + ("node", "invest_method"), + ("unit", "invest_method"), + ("connection", "invest_method"), + } + for key in excluded: + assert key not in PERIOD_TIME_PARAMS + + +def test_known_dual_axis_members_present(): + for key in [ + ("group", "co2_price"), + ("flowGroup", "min_instant_flow"), + ("flowGroup", "max_instant_flow"), + ("node", "availability"), + ("node", "storage_state_reference_value"), + ("unit", "availability"), + ("connection", "availability"), + ("unit", "efficiency"), + ("connection", "efficiency"), + ("unit", "efficiency_at_min_load"), + ("unit", "min_load"), + ("commodity", "price"), + ("unit__inputNode", "other_operational_cost"), + ("unit__outputNode", "other_operational_cost"), + ("connection", "other_operational_cost"), + ("reserve__upDown__group", "reservation"), + ]: + assert key in PERIOD_TIME_PARAMS + + # Period-only members are included on purpose (so a stray Map(time) + # raises in the §3.3 check). + for key in [ + ("group", "co2_max_period"), + ("unit", "startup_cost"), + ("unit", "invest_max_period"), + ("connection", "cumulative_min_capacity"), + ]: + assert key in PERIOD_TIME_PARAMS From 411358f88726aca896decc6a7dc1812457334565 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Fri, 2 Oct 2026 14:43:57 +0300 Subject: [PATCH 62/87] fix(min_load): route p_min_load through the (p,d,t) shape resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unit.min_load allows period/time/period-time Maps, but the minFlow_minload constraint read it via _entity_scalar_explicit, which drops the Map index column — a Map yielded multiple (unit,value) rows that polar_high then SUMMED in the constraint, inflating the floor. Confirmed end-to-end: a period Map {d1:0.4,d2:0.3} gave floor 0.7 in every period (an hourly Map would sum thousands of values → floor >>1, unit can't run). (spec fix_map_reading.md §5.) Fix: produce p_min_load as a (p,d,t) Param via the same _eff_param_to_pdt template the efficiency curve already uses (resolve_param_shape + broadcast_to_period_time), instead of the scalar read. - _derived_params: p_min_load_pdt_from_source wired into apply_derived_b beside p_slope; added to DERIVED_B_FIELDS. - _direct_params: dropped the scalar (p,) assignment + unused helper. - input.py: field now (p,d,t); synthetic/rolling re-wire beside SECTION-1/ RESERVE-1 so the ONLINE-gate field isn't None on early-return sub-solves. - _dump_csvs: min_load moves to pdtProcess.csv (period+time), mirroring availability. - model.py unchanged (polar_high broadcasts (p,d,t) identically to (p,)). Scalar min_load byte-identical (0.4→0.4); only Map changes: period Map now 0.4/0.3 per period, time Map per timestep. Tests: new test_min_load_pdt.py (6, producer + end-to-end), delta4 retargeted to the derived path; b04_uc + flex_coal_min_load scalar parity green; min_load_rolling (synthetic) green; csv-dump round-trip green. FLEXTOOL_AUTOSCALE_STRICT=1; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_params.py | 56 ++++ flextool/engine_polars/_direct_params.py | 19 +- flextool/engine_polars/_dump_csvs.py | 12 +- flextool/engine_polars/input.py | 39 ++- .../constraints/test_min_load_pdt.py | 263 ++++++++++++++++++ .../test_direct_params_delta4.py | 32 ++- 6 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 tests/engine_polars/constraints/test_min_load_pdt.py diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 8a7c4a983..c1f9ff30d 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -2918,6 +2918,52 @@ def p_slope_from_source(source: "InputSource", return Param(("p", "d", "t"), out) +# --------------------------------------------------------------------------- +# §3.3.4b — p_min_load (minFlow_minload floor), shape-correct (p, d, t) +# --------------------------------------------------------------------------- + + +def p_min_load_pdt_from_source(source: "InputSource", + dt: pl.DataFrame, + ) -> "Param | None": + """``unit.min_load`` → ``Param(("p", "d", "t"), [p, d, t, value])``. + + Routed through the authoritative shape resolver (:func:`_eff_param_to_pdt`, + the same path the efficiency-curve ``p_slope`` / ``p_section`` already use) + so a period / time / period-time ``Map`` keeps its per-(d, t) semantics. + + This replaces the old ``(p,)`` direct param (``_direct_params`` + ``p_min_load_from_source`` → ``_entity_scalar_explicit``) whose + ``.select(dim, "value")`` dropped the Map index column: a Map min_load + then yielded one ``(unit, value)`` row per key, and polar_high **summed** + the duplicate ``(p)`` keys in the ``minFlow_minload`` constraint + (``model.py``) — a period Map ``{p1: 0.4, p2: 0.3}`` produced a floor of + 0.7 in *every* period. See ``specs/fix_map_reading.md`` §5. + + A **scalar** min_load is broadcast to the full ``(d, t)`` grid, so the + constraint RHS (``v_online · min_load``) is byte-identical to the old + ``(p,)`` broadcast — only Map authoring changes behaviour. + + Returns ``None`` when the parameter has no usable rows (mirrors the old + helper's ``None`` contract so the ONLINE feature-gate check and the + constraint's ``process_minload`` inner-join are unchanged). + """ + if dt is None or dt.height == 0: + return None + # Defend axis-aware join keys on the incoming frame param, mirroring + # ``p_slope_from_source``'s treatment of ``dt`` before ``_eff_param_to_pdt``. + _enums = get_global_axis_enums() + if _enums is not None: + dt = cast_frame_axes(dt, _enums) + lf = _eff_param_to_pdt(source, "unit", "min_load", dt, "value") + if lf is None: + return None + out = lf.sort("p", "d", "t").collect() + if out.height == 0: + return None + return Param(("p", "d", "t"), out) + + # =========================================================================== # Γ.3.B integration entrypoint # =========================================================================== @@ -2928,6 +2974,7 @@ def p_slope_from_source(source: "InputSource", "p_flow_constraint_coef", "p_pssdt_varCost", "p_slope", + "p_min_load", ) @@ -3110,6 +3157,15 @@ def apply_derived_b( if dt_csv is not None and dt_csv.height > 0 and classified.height > 0: flex_data.p_slope = p_slope_from_source(source, dt_csv, classified) + # ─── §3.3.4b p_min_load (minFlow_minload floor, (p, d, t)) ────────── + # Shape-correct (p, d, t) min_load routed through the efficiency + # resolver so a Map isn't summed in the minFlow_minload constraint. + # Gated on dt only (min_load applies to any online+minload unit, + # independent of the ct_method classifier). See + # ``p_min_load_pdt_from_source`` / ``specs/fix_map_reading.md`` §5. + if dt_csv is not None and dt_csv.height > 0: + flex_data.p_min_load = p_min_load_pdt_from_source(source, dt_csv) + # ─── §F.1 p_unitsize (Δ.10 cluster F) ───────────────────────────── # Per-process unitsize cascade restricted to processes appearing in # ``pss``. Mirrors ``input.py:800-825``'s diff --git a/flextool/engine_polars/_direct_params.py b/flextool/engine_polars/_direct_params.py index 5a2b9bd89..02c1a37b7 100644 --- a/flextool/engine_polars/_direct_params.py +++ b/flextool/engine_polars/_direct_params.py @@ -518,13 +518,11 @@ def p_state_start_from_source(source: "InputSource") -> Param | None: # §5.2.7 — process scalars sliced from p_process.csv -def p_min_load_from_source(source: "InputSource") -> Param | None: - """``unit.min_load`` → ``Param(("p",), [p, value])``. - - Default 0.0 (schema). CSV path filters by processParam=='min_load' - and emits rows only when explicit; we mirror via ``parameter_explicit``. - """ - return _entity_scalar_explicit(source, "unit", "min_load", "p") +# NOTE: ``unit.min_load`` is NOT a direct (p,) scalar. It is produced as a +# shape-correct (p, d, t) Param by ``_derived_params.apply_derived_b`` (via +# ``p_min_load_pdt_from_source``) so a period/time Map keeps its per-(d, t) +# semantics instead of being summed in the minFlow_minload constraint. +# See ``specs/fix_map_reading.md`` §5. # §5.18 — connection scalars @@ -1947,7 +1945,12 @@ def apply_direct_params_a(source: "InputSource", flex_data.p_state_start = p_state_start_from_source(source) # ─── Δ.4 second wave — process scalars (online / UC feature) ──────── - flex_data.p_min_load = p_min_load_from_source(source) + # ``p_min_load`` is produced authoritatively as a shape-correct + # (p, d, t) Param by ``_derived_params.apply_derived_b`` (via + # ``p_min_load_pdt_from_source``), so a period/time Map isn't summed in + # the minFlow_minload constraint. The synthetic / rolling sub-solve + # early-return in ``input.py`` re-wires it (passes 3-10 are skipped + # there). See ``specs/fix_map_reading.md`` §5. # Per-output-arc min_capacity_coefficient — scales the minFlow_minload # floor. Assigned in pass 1a so it is present on the synthetic / rolling # sub-solve path too (passes 3-10 are skipped there). diff --git a/flextool/engine_polars/_dump_csvs.py b/flextool/engine_polars/_dump_csvs.py index 3ded846a3..89bd294a1 100644 --- a/flextool/engine_polars/_dump_csvs.py +++ b/flextool/engine_polars/_dump_csvs.py @@ -549,6 +549,11 @@ def _write_input_p_entity_unitsize_wide(data: "FlexData", _PDT_PROCESS_SLICES = { "availability": "p_process_availability", + # ``p_min_load`` is a shape-correct (p, d, t) Param (produced by + # ``_derived_params.apply_derived_b``), so it dumps to the period+time + # keyed ``pdtProcess.csv`` — not the scalar ``p_process.csv`` — mirroring + # ``availability``. See ``specs/fix_map_reading.md`` §5. + "min_load": "p_min_load", } _PDT_COMMODITY_SLICES = { @@ -669,9 +674,10 @@ def _write_pd_sliced(data: "FlexData", sd_dir: Path) -> None: } # p_process.csv columns: process, processParam, p_process -_P_PROCESS_SLICES = { - "min_load": "p_min_load", -} +# NOTE: ``min_load`` moved to ``_PDT_PROCESS_SLICES`` (pdtProcess.csv) when +# ``p_min_load`` became a shape-correct (p, d, t) Param — see +# ``specs/fix_map_reading.md`` §5. +_P_PROCESS_SLICES: dict[str, str] = {} def _merge_param_slice(path: Path, new_rows: pl.DataFrame, diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 0f4db3389..0e570c777 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -637,7 +637,7 @@ class FlexData: p_online_dt: pl.DataFrame | None = None # set: (p, d, t) — UC var domain pdt_online_linear: pl.DataFrame | None = None # (p, d, t) — startup-cost obj index, linear pdt_online_integer: pl.DataFrame | None = None # (p, d, t) — startup-cost obj index, integer - p_min_load: Param | None = None # (p,) + p_min_load: Param | None = None # (p, d, t) p_process_sink_min_capacity_coef: Param | None = None # (p, sink) — capacity_min_coeff: minFlow_minload floor multiplier p_startup_cost: Param | None = None # (p, d) p_section: Param | None = None # (p, d, t) @@ -2310,8 +2310,11 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, sd / "p_online_dt_set.csv").pipe(rename_to_axis, {"process": "p", "step": "t"}) p_odt = p_odt.select("p", "period", "t").pipe(rename_to_axis, {"period": "d"}) - # Δ.12-drop: ``p_min_load`` produced authoritatively by - # ``apply_direct_params.p_min_load_from_source``. Seed dropped. + # Δ.12-drop: ``p_min_load`` produced authoritatively as a shape-correct + # (p, d, t) Param by ``_derived_params.apply_derived_b`` (via + # ``p_min_load_pdt_from_source``); the synthetic / no-workdir early-returns + # in ``_apply_db_overrides`` re-wire it. CSV seed dropped. See + # ``specs/fix_map_reading.md`` §5. p_min_load = None # startup_cost is per (p, d) — produced by ``apply_direct_params`` @@ -4905,6 +4908,14 @@ def _timed(label, fn, *args, **kwargs): # (``_SourceShim`` carries ``work_folder``). _loadflex_prof("apply_db_overrides:pass_direct_params_b") _timed("1b direct_params_b", _dp.apply_direct_params_b, db_reader, flex_data) + # MINLOAD-1 (no-workdir early-return): ``apply_derived_b`` won't run + # on this path either, so re-wire the (p, d, t) ``p_min_load`` ONLINE + # gate field from the CSV-loaded ``flex_data.dt`` (see the synthetic + # MINLOAD-1 patch below and ``specs/fix_map_reading.md`` §5). + _dt_noworkdir = getattr(flex_data, "dt", None) + if _dt_noworkdir is not None and _dt_noworkdir.height > 0: + flex_data.p_min_load = _drv.p_min_load_pdt_from_source( + db_reader, _dt_noworkdir) return workdir_path = Path(workdir) @@ -4993,6 +5004,28 @@ def _wire_section_for_synthetic_solve(): _loadflex_prof("apply_db_overrides:pass_synthetic_section") _timed("c.synth section", _wire_section_for_synthetic_solve) + # MINLOAD-1 — same class of gap as SECTION-1 / RESERVE-1. The + # synthetic-solve early-return skips passes 3-10, so + # ``apply_derived_b``'s ``p_min_load`` producer never runs. After + # this fix moved ``p_min_load`` out of pass 1a (``apply_direct_params``) + # into ``apply_derived_b`` (so a Map min_load becomes a shape-correct + # (p, d, t) Param instead of a summed (p,) scalar — see + # ``specs/fix_map_reading.md`` §5), a rolling / synthetic sub-solve + # would leave ``p_min_load`` ``None``. ``p_min_load`` is an ONLINE + # feature-gate field (``model.py`` ``_check(ONLINE)``), so every online + # unit on the synthetic path would fail the check. + # ``p_min_load_pdt_from_source`` is solve-agnostic (no ``active_solve`` + # filter) and reads the CSV-loaded ``flex_data.dt``, so calling it here + # mirrors ``apply_derived_b`` exactly. + def _wire_min_load_for_synthetic_solve(): + dt = getattr(flex_data, "dt", None) + if dt is None or dt.height == 0: + return + flex_data.p_min_load = _drv.p_min_load_pdt_from_source( + db_reader, dt) + _loadflex_prof("apply_db_overrides:pass_synthetic_min_load") + _timed("c.synth min_load", _wire_min_load_for_synthetic_solve) + # INFLOW-1 — synthetic solves skip ``apply_derived_c``; re-wire the # inflow signed-split so the non_sync / capacity_margin demand budget # isn't silently lost. ``apply_derived_c`` (``_derived_params`` §3.12) diff --git a/tests/engine_polars/constraints/test_min_load_pdt.py b/tests/engine_polars/constraints/test_min_load_pdt.py new file mode 100644 index 000000000..15db3429e --- /dev/null +++ b/tests/engine_polars/constraints/test_min_load_pdt.py @@ -0,0 +1,263 @@ +"""End-to-end guard for the ``unit.min_load`` Map double-count fix. + +Background (``specs/fix_map_reading.md`` §5): ``p_min_load`` used to be a +``(p,)`` Direct Param whose scalar helper +(``_direct_params._entity_scalar_explicit``) did ``.select(dim, "value")`` and +**dropped the Map index column**. A period Map ``{p1: 0.4, p2: 0.3}`` then +unrolled to two ``(unit, value)`` rows for the same unit, and polar_high +**summed** the duplicate ``(p)`` keys in the ``minFlow_minload`` constraint +(``model.py``) — producing a floor of 0.7 in *every* period. + +The fix routes ``p_min_load`` through the efficiency shape resolver +(``_derived_params.p_min_load_pdt_from_source`` → ``_eff_param_to_pdt``) so it +becomes a shape-correct ``(p, d, t)`` Param, exactly like ``p_slope`` / +``p_section`` already are. + +These tests exercise the full chain: the producer reads the Map correctly, +and the real ``minFlow_minload`` constraint applies the correct per-period / +per-timestep floor. + +Model geometry (per repro): a single ``online_integer`` unit ``u`` with +``unitsize = 100``, node demand 10 MW/step (= 0.1 per unitsize, well below the +floor) and *cheap* down-penalty. With the floor binding and excess dumped, +``v_flow`` (per-unitsize) reads back the applied floor directly. +""" +from __future__ import annotations + +from typing import Any + +import polars as pl +import pytest + +from polar_high import Param, Problem +from flextool.engine_polars import build_flextool, InMemoryReader +from flextool.engine_polars import _derived_params as drv +from flextool.engine_polars.input import FlexData + +from .conftest import solver_options + + +# Two periods, two steps each — enough to show a period Map differ per period. +_PERIODS = ["d1", "d2"] +_STEPS = 2 +_DT = pl.DataFrame( + [{"d": d, "t": f"t{k:02d}"} for d in _PERIODS for k in range(1, _STEPS + 1)] +) + + +def _build_uc_2period(p_min_load_param: Param) -> FlexData: + """Single ``online_integer`` unit over 2 periods × 2 steps, floor-binding. + + ``p_min_load_param`` is injected verbatim (the thing under test). Demand + is low and down-slack cheap, so ``v_flow`` reveals the floor. + """ + dt = _DT + p_step = Param(("d", "t"), dt.with_columns(value=pl.lit(1.0))) + p_rp = Param(("d", "t"), dt.with_columns(value=pl.lit(1.0))) + p_infl = Param(("d",), + pl.DataFrame({"d": _PERIODS, "value": [1.0] * len(_PERIODS)})) + p_psh = Param(("d",), + pl.DataFrame({"d": _PERIODS, "value": [1.0] * len(_PERIODS)})) + + nb = pl.DataFrame({"n": ["n"]}) + nb_dt = nb.join(dt, how="cross") + p_inflow = Param(("n", "d", "t"), + nb_dt.with_columns(value=pl.lit(-10.0)).select("n", "d", "t", "value")) + p_pen_up = Param(("n", "d", "t"), + nb_dt.with_columns(value=pl.lit(1e6)).select("n", "d", "t", "value")) + # Cheap down-penalty so the unit can dump the above-demand floor output. + p_pen_dn = Param(("n", "d", "t"), + nb_dt.with_columns(value=pl.lit(0.01)).select("n", "d", "t", "value")) + + pss = pl.DataFrame({"p": ["u"], "source": ["FUEL_n"], "sink": ["n"]}) + pss_eff = pss.clone() + pss_noEff = pl.DataFrame( + schema={"p": pl.Utf8, "source": pl.Utf8, "sink": pl.Utf8}) + pss_dt = pss.join(dt, how="cross") + flow_to_n = pss.with_columns(n=pl.col("sink")) + flow_from_commodity_eff = pl.DataFrame( + {"p": ["u"], "source": ["FUEL_n"], "sink": ["n"], "c": ["FUEL"]}) + flow_from_commodity_noEff = pl.DataFrame( + schema={"p": pl.Utf8, "source": pl.Utf8, "sink": pl.Utf8, "c": pl.Utf8}) + + p_unitsize = Param(("p",), pl.DataFrame({"p": ["u"], "value": [100.0]})) + p_flow_upper = Param(("p", "source", "sink", "d", "t"), + pss_dt.with_columns(value=pl.lit(1.0)) + .select("p", "source", "sink", "d", "t", "value")) + p_slope = Param(("p", "d", "t"), + pss_dt.select("p", "d", "t").with_columns(value=pl.lit(1.0))) + p_commodity_price = Param(("c", "d", "t"), + dt.with_columns(c=pl.lit("FUEL"), value=pl.lit(1.0)) + .select("c", "d", "t", "value")) + + process_online = pl.DataFrame({"p": ["u"]}) + process_minload = pl.DataFrame({"p": ["u"]}) + p_online_dt = pss_dt.select("p", "d", "t").unique() + # Integer commitment → v_online ∈ {0, 1} so the floor binds crisply. + process_online_integer = pl.DataFrame({"p": ["u"]}) + pdt_online_integer = p_online_dt.clone() + + p_startup_cost = Param(("p", "d"), + pl.DataFrame({"p": ["u"] * len(_PERIODS), "d": _PERIODS, + "value": [5.0] * len(_PERIODS)})) + p_process_existing_count = Param(("p", "d"), + pl.DataFrame({"p": ["u"] * len(_PERIODS), "d": _PERIODS, + "value": [1.0] * len(_PERIODS)})) + + # Cyclic dtttdt within each period (2 steps each). + rows = [] + for d in _PERIODS: + rows += [ + {"d": d, "t": "t01", "t_previous": "t02", + "t_previous_within_timeset": "t02", "d_previous": d, + "t_previous_within_solve": "t02"}, + {"d": d, "t": "t02", "t_previous": "t01", + "t_previous_within_timeset": "t01", "d_previous": d, + "t_previous_within_solve": "t01"}, + ] + dtttdt = pl.DataFrame(rows) + + return FlexData( + dt=dt, p_step_duration=p_step, p_timestep_weight=p_rp, + p_inflation_op=p_infl, p_period_share=p_psh, + nodeBalance=nb, nodeBalance_dt=nb_dt, + p_inflow=p_inflow, p_penalty_up=p_pen_up, p_penalty_down=p_pen_dn, + process_source_sink=pss, process_source_sink_eff=pss_eff, + process_source_sink_noEff=pss_noEff, pss_dt=pss_dt, + flow_to_n=flow_to_n, + flow_from_commodity_eff=flow_from_commodity_eff, + flow_from_commodity_noEff=flow_from_commodity_noEff, + p_unitsize=p_unitsize, p_flow_upper=p_flow_upper, + p_slope=p_slope, p_commodity_price=p_commodity_price, + process_online=process_online, + process_online_integer=process_online_integer, + process_minload=process_minload, + p_online_dt=p_online_dt, + pdt_online_integer=pdt_online_integer, + p_min_load=p_min_load_param, + p_startup_cost=p_startup_cost, + p_process_existing_count=p_process_existing_count, + dtttdt=dtttdt, + ) + + +def _solve(data: FlexData) -> tuple[Problem, Any]: + pb = Problem() + build_flextool(pb, data) + sol = pb.solve(options=solver_options()) + return pb, sol + + +def _floor_per_period(sol) -> dict[str, float]: + """Return the min v_flow per period — the binding floor.""" + vf = sol.value("v_flow").sort(["d", "t"]) + out: dict[str, float] = {} + for d in _PERIODS: + out[d] = float(vf.filter(pl.col("d") == d)["value"].min()) + return out + + +def _unit_reader(min_load_frame: pl.DataFrame) -> InMemoryReader: + return InMemoryReader( + entities={"unit": pl.DataFrame({"name": ["u"]})}, + parameters={("unit", "min_load"): min_load_frame}, + ) + + +# --------------------------------------------------------------------------- +# Producer-level: the Map keeps per-(d, t) semantics (not summed). + +def test_period_map_producer_not_summed(): + """A period Map resolves to per-period (p, d, t) values, not a sum.""" + src = _unit_reader(pl.DataFrame({ + "name": ["u", "u"], + "period": ["d1", "d2"], + "value": [0.4, 0.3], + })) + p = drv.p_min_load_pdt_from_source(src, _DT) + assert isinstance(p, Param) + assert p.dims == ("p", "d", "t") + frame = p.frame.collect() if hasattr(p.frame, "collect") else p.frame + by_period = { + d: sorted(frame.filter(pl.col("d") == d)["value"].to_list()) + for d in _PERIODS + } + assert by_period["d1"] == pytest.approx([0.4, 0.4]) + assert by_period["d2"] == pytest.approx([0.3, 0.3]) + + +def test_scalar_producer_broadcasts(): + src = _unit_reader(pl.DataFrame({"name": ["u"], "value": [0.4]})) + p = drv.p_min_load_pdt_from_source(src, _DT) + frame = p.frame.collect() if hasattr(p.frame, "collect") else p.frame + assert frame["value"].to_list() == pytest.approx([0.4] * 4) + + +# --------------------------------------------------------------------------- +# End-to-end: the real minFlow_minload constraint applies the right floor. + +def test_period_map_min_load_floor_per_period(): + """Period Map ``{d1: 0.4, d2: 0.3}`` → floor 0.4 in d1 and 0.3 in d2. + + Pre-fix the two Map rows summed to a 0.7 floor in *every* period. + """ + src = _unit_reader(pl.DataFrame({ + "name": ["u", "u"], + "period": ["d1", "d2"], + "value": [0.4, 0.3], + })) + p_min_load = drv.p_min_load_pdt_from_source(src, _DT) + _, sol = _solve(_build_uc_2period(p_min_load)) + assert sol.optimal + floors = _floor_per_period(sol) + assert floors["d1"] == pytest.approx(0.4, abs=1e-6) + assert floors["d2"] == pytest.approx(0.3, abs=1e-6) + # The bug would have given 0.7 in both. + assert floors["d1"] != pytest.approx(0.7, abs=1e-6) + assert floors["d2"] != pytest.approx(0.7, abs=1e-6) + + +def test_scalar_min_load_floor_unchanged(): + """Scalar min_load 0.4 → floor 0.4 in every period (byte-parity of + behaviour with the old (p,) broadcast).""" + src = _unit_reader(pl.DataFrame({"name": ["u"], "value": [0.4]})) + p_min_load = drv.p_min_load_pdt_from_source(src, _DT) + _, sol = _solve(_build_uc_2period(p_min_load)) + assert sol.optimal + floors = _floor_per_period(sol) + assert floors["d1"] == pytest.approx(0.4, abs=1e-6) + assert floors["d2"] == pytest.approx(0.4, abs=1e-6) + + +def test_scalar_min_load_parity_with_direct_param(): + """A hand-built (p,) scalar Param (the old direct-param shape) and the + new (p, d, t) producer give the identical LP floor — the scalar path is + unchanged by the fix.""" + old_shape = Param(("p",), pl.DataFrame({"p": ["u"], "value": [0.4]})) + _, sol_old = _solve(_build_uc_2period(old_shape)) + src = _unit_reader(pl.DataFrame({"name": ["u"], "value": [0.4]})) + new_shape = drv.p_min_load_pdt_from_source(src, _DT) + _, sol_new = _solve(_build_uc_2period(new_shape)) + assert sol_old.optimal and sol_new.optimal + assert _floor_per_period(sol_old) == pytest.approx( + _floor_per_period(sol_new)) + + +def test_time_map_min_load_floor_per_timestep(): + """A time Map ``{t01: 0.4, t02: 0.3}`` → per-timestep floor, same in both + periods (the time axis broadcasts across periods).""" + # InMemoryReader passes the authored frame through verbatim; a time map + # carries a ``t`` column (SpineDbReader renames ``time`` → ``t`` on read). + src = _unit_reader(pl.DataFrame({ + "name": ["u", "u"], + "t": ["t01", "t02"], + "value": [0.4, 0.3], + })) + p_min_load = drv.p_min_load_pdt_from_source(src, _DT) + assert p_min_load is not None + _, sol = _solve(_build_uc_2period(p_min_load)) + assert sol.optimal + vf = sol.value("v_flow").sort(["d", "t"]) + for d in _PERIODS: + got = vf.filter(pl.col("d") == d).sort("t")["value"].to_list() + assert got == pytest.approx([0.4, 0.3], abs=1e-6) diff --git a/tests/engine_polars/test_direct_params_delta4.py b/tests/engine_polars/test_direct_params_delta4.py index 2c518424a..0cf52efa3 100644 --- a/tests/engine_polars/test_direct_params_delta4.py +++ b/tests/engine_polars/test_direct_params_delta4.py @@ -6,10 +6,16 @@ * ``p_state_self_discharge`` — ``node.self_discharge_loss`` * ``p_state_start`` — ``node.storage_state_start`` -* ``p_min_load`` — ``unit.min_load`` * ``p_connection_susceptance`` — ``connection.susceptance`` * ``p_commodity_unitsize`` — ``commodity.unitsize`` +``p_min_load`` (``unit.min_load``) was formerly a Δ.4 scalar Direct Param +here, but is now produced as a shape-correct (p, d, t) Param by +``_derived_params.apply_derived_b`` so a period/time Map isn't summed in the +minFlow_minload constraint (see ``specs/fix_map_reading.md`` §5). Its +coverage moved to ``test_min_load_pdt.py`` and the derived-producer unit test +below. + Each helper mirrors the CSV path's "explicit rows only" semantics via :meth:`InputSource.parameter_explicit`. The DB-direct overlay runs after the CSV seed in ``load_flextool``; equality of the two @@ -82,7 +88,18 @@ def test_p_state_start_inmemory_explicit(): assert p.frame.sort("n")["value"].to_list() == pytest.approx([0.5]) -def test_p_min_load_inmemory_explicit(): +def test_p_min_load_pdt_inmemory_explicit_scalar(): + """``p_min_load`` is now a derived (p, d, t) Param (not a Δ.4 scalar). + + A scalar min_load broadcasts to the full (d, t) grid — byte-identical + floor to the old (p,) broadcast. See ``specs/fix_map_reading.md`` §5. + """ + from flextool.engine_polars import _derived_params as drv + + dt = pl.DataFrame({ + "d": ["2025", "2025", "2030", "2030"], + "t": ["t01", "t02", "t01", "t02"], + }) src = InMemoryReader( entities={"unit": pl.DataFrame({"name": ["coal_chp"]})}, parameters={ @@ -91,10 +108,12 @@ def test_p_min_load_inmemory_explicit(): }), }, ) - p = dp.p_min_load_from_source(src) + p = drv.p_min_load_pdt_from_source(src, dt) assert isinstance(p, Param) - assert p.dims == ("p",) - assert p.frame.sort("p")["value"].to_list() == pytest.approx([0.4]) + assert p.dims == ("p", "d", "t") + frame = p.frame.collect() if hasattr(p.frame, "collect") else p.frame + # Scalar broadcast → 0.4 for every (d, t). + assert frame["value"].to_list() == pytest.approx([0.4] * 4) def test_p_connection_susceptance_inmemory_explicit(): @@ -215,7 +234,8 @@ def _frame(x): @pytest.mark.parametrize("field", [ "p_state_self_discharge", "p_state_start", - "p_min_load", + # ``p_min_load`` moved to the derived (p, d, t) path — see + # ``test_min_load_pdt.py`` (specs/fix_map_reading.md §5). "p_commodity_unitsize", # Δ.4b additions: "p_startup_cost", From 43635f142e06e6760db05d8e66f1a13db2c0ede9 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 5 Oct 2026 15:18:00 +0300 Subject: [PATCH 63/87] test(stochastic): coverage-first rolling stochastic NA e2e (W2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a solving e2e for the rolling stochastic fixture `1_week_rolling_wind`, landed BEFORE the W1/W3 fix (spec stochastics_reassessment.md §E: coverage for rolling must come first so the fix is observable and no future change silently re-breaks it). The rolling solve currently crashes at the E0 branch-reveal validation (_stochastic.py:466-476, FlexToolConfigError). The test documents that via a module-level strict xfail(raises=FlexToolConfigError) keyed on a one-line switch (W1_W3_LANDED), so the suite stays green now and a single edit flips all three tests to real pass-assertions once W1/W3 merge. strict=True guarantees any accidental future pass xpass-fails the suite, forcing the flip. Assertions (post-flip): - solve completes: 7 rolls, each strictly optimal; - PRIMARY structural signal: per roll, flex_data.period_branch_full (non-empty d!=b sub-cohort) + dt_non_anticipativity are populated — the exact precondition the operational-NA gate (model.py:4519-4522) requires; - storage NA ties v_state across branches at tied (d,t) cells (two reconciliation advisories recorded at the flip switch for the W1/W3 implementer). Builds the DB from JSON via stochastic_db_url (invariant #3); runs under FLEXTOOL_AUTOSCALE_STRICT=1 (invariant #1). No engine code touched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- .../test_rolling_stochastic_na.py | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 tests/engine_polars/test_rolling_stochastic_na.py diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py new file mode 100644 index 000000000..49b44b53d --- /dev/null +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -0,0 +1,263 @@ +"""Coverage-first solving e2e for the rolling stochastic UC. + +Fixture ``1_week_rolling_wind`` (scenario in ``tests/fixtures/stochastics.json``, +solve ``1week_rolling``): a 168-step week (timeset ``1week`` / +``timeset_duration = 168``) solved as ``solve_mode = rolling_window`` with +``rolling_solve_horizon = 96`` and ``rolling_solve_jump = 24``. The jump of +24 over a 168-step horizon yields SEVEN rolls (roll starts t0001, t0025, +t0049, t0073, t0097, t0121, t0145 — matching the seven ``stochastic_branches`` +reveal points). Three equal-probability branches (``upper`` / ``lower`` / +``mid``) fan at every reveal alongside the ``realized`` anchor. + +Why this test exists / what it protects +--------------------------------------- +Today the rolling solve CRASHES during E0 branch-reveal validation +(``flextool/engine_polars/_stochastic.py:466-476`` raises +``FlexToolConfigError`` — "a stochastic branch must start at the FIRST step +of its branching period"). This is the W1/W3 defect tracked in +``specs/stochastics_reassessment.md`` §A.1 / §A.2 / §E / §F. + +This file lands BEFORE that fix. It keeps the suite GREEN now by documenting +the crash as a strict xfail (``raises=FlexToolConfigError``) and flips to real +pass-assertions with a SINGLE one-line change (``W1_W3_LANDED = True``) once +W1 + W3 merge. Until then every test memoises and re-raises the one +``FlexToolConfigError`` so pytest reports ``xfailed`` (never ``failed`` / +``xpassed``). + +The structural signal the tests assert post-flip is the exact precondition +the operational-NA gate (``model.py:4519-4522``) requires to emit the four +``non_anticipativity_*`` constraint families: a per-roll ``flex_data`` whose +``period_branch_full`` (with a non-empty ``d != b`` sub-cohort) and +``dt_non_anticipativity`` frames are both populated. Those frames are +produced authoritatively by ``apply_branch_cluster`` within +``apply_derived_g`` (``flextool/engine_polars/input.py:3842-3845``) — the +``_derived_branch.py`` overlay is only a seed-preserving pass, not the +producer. +""" +from __future__ import annotations + +import os + +import polars as pl +import pytest + +from flextool.engine_polars._orchestration import run_chain_from_db +from flextool.engine_polars._solve_state import FlexToolConfigError + +SCENARIO = "1_week_rolling_wind" + +# Deterministic roll count (see module docstring): 168-step horizon, +# rolling_solve_jump = 24 → 7 rolls. +EXPECTED_ROLLS = 7 + +# >>> ONE-LINE FLIP: set True once W1 + W3 are merged. <<< +# +# Before flipping, reconcile the POST-FLIP assertions in +# ``test_rolling_na_ties_storage_across_branches`` against the real per-roll NA +# frames (they cannot be observed until W1/W3 exist — auto-memory +# "Rolling stochastic broken" records that the current stack drops all NA +# coupling in rolling). Two review advisories: +# (1) The storage-NA family ties per-(n,t) NET CHARGE (a delta), not absolute +# ``v_state`` (model.py:4505). Absolute-state equality holds only when the +# tied window is a prefix from a shared initial state; a mid-horizon roll +# with a per-branch-free initial state can satisfy the net-charge tie with +# a constant ``v_state`` offset. If so, assert on the tied net-charge LHS, +# not absolute ``v_state``. +# (2) This fixture reveals multiple times within one 96-step horizon, so tie +# windows differ per reveal. Restrict the cross-branch comparison to the +# exact tied (d, t) PAIRS, not just the union of tied ``t`` across all +# branch-periods, or a legitimately-diverged branch gets compared against a +# still-tied one. +# The PRIMARY structural signal (test_rolling_na_frames_couple_branches_per_roll) +# is unaffected by both and is the load-bearing coverage assertion. +W1_W3_LANDED = False + +pytestmark = [ + pytest.mark.solver, + pytest.mark.xfail( + not W1_W3_LANDED, + strict=True, + raises=FlexToolConfigError, + reason="E0 validation crashes rolling stochastic UC " + "(flextool/engine_polars/_stochastic.py:466-476); " + "W1/W3 land after this coverage test — spec §A.1/§E/§F.", + ), +] + + +@pytest.fixture(scope="module") +def rolling_result(stochastic_db_url, test_solver_config_dir, tmp_path_factory): + """Run the rolling stochastic scenario end-to-end ONCE for the module. + + Memoises either the per-step dict (post-fix) or the + ``FlexToolConfigError`` the E0 validation raises today, so each test can + re-raise it in its own body and report a strict xfail. The + ``run_chain_from_db`` call runs under ``FLEXTOOL_AUTOSCALE_STRICT=1`` so + that any future autoscale-registry gap in the NA path is loud rather than + silently reverting to an un-scaled LP (invariant #1). + """ + work_folder = tmp_path_factory.mktemp("rolling_stochastic_na") + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + steps = run_chain_from_db( + input_db_url=stochastic_db_url, + scenario_name=SCENARIO, + work_folder=work_folder, + solver_config_dir=test_solver_config_dir, + warm=False, + keep_solutions=True, + ) + return {"steps": steps, "error": None} + except FlexToolConfigError as exc: + return {"steps": None, "error": exc} + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +def _steps(rolling_result): + """Return the per-step dict, or re-raise the memoised E0 crash. + + Pre-fix this re-raises ``FlexToolConfigError`` inside the test body, which + the module-level strict xfail converts to ``xfailed``. Post-fix it hands + back the live ``{solve_name: OrchestrationStep}`` dict. + """ + if rolling_result["error"] is not None: + raise rolling_result["error"] + return rolling_result["steps"] + + +def _pick(cols, *candidates): + for c in candidates: + if c in cols: + return c + return cols[0] + + +def test_rolling_stochastic_solve_completes(rolling_result): + """Every roll of the rolling stochastic solve reaches a strict optimum.""" + steps = _steps(rolling_result) + assert len(steps) == EXPECTED_ROLLS, ( + f"expected {EXPECTED_ROLLS} rolls (168-step horizon, " + f"rolling_solve_jump=24), got {len(steps)}" + ) + assert all(s.optimal for s in steps.values()), { + name: s.optimal for name, s in steps.items() + } + + +def test_rolling_na_frames_couple_branches_per_roll(rolling_result): + """PRIMARY structural signal: each roll carries the NA precondition. + + ``apply_branch_cluster`` (in ``apply_derived_g``, + ``flextool/engine_polars/input.py:3842-3845``) must populate, on every + roll's ``flex_data``, both the ``period_branch_full`` (d, b) anchor→sibling + map — with a non-empty ``d != b`` sub-cohort — and the + ``dt_non_anticipativity`` (d, t) tie set. That is exactly the gate the + operational-NA families check at ``model.py:4519-4522`` before emitting + the ``non_anticipativity_*`` constraints. + """ + steps = _steps(rolling_result) + for name, s in steps.items(): + fd = s.flex_data + assert fd is not None, f"roll {name!r} retained no flex_data" + + pbf = fd.period_branch_full + assert pbf is not None and pbf.height > 0, ( + f"roll {name!r}: empty period_branch_full" + ) + assert fd.dt_non_anticipativity is not None, ( + f"roll {name!r}: dt_non_anticipativity is None" + ) + assert fd.dt_non_anticipativity.height > 0, ( + f"roll {name!r}: empty dt_non_anticipativity" + ) + + # The d != b sub-cohort is the real anchor→sibling tie set the NA + # gate filters to. Cast both axes to Utf8 before comparing — under + # Phase 4 activation ``d`` and ``b`` are different Enum vocabularies + # and polars refuses ``!=`` across them. + off_diag = pbf.with_columns( + pl.col("d").cast(pl.Utf8), + pl.col("b").cast(pl.Utf8), + ).filter(pl.col("d") != pl.col("b")) + assert off_diag.height > 0, ( + f"roll {name!r}: period_branch_full has no d != b rows" + ) + + +def test_rolling_na_ties_storage_across_branches(rolling_result): + """Storage NA ties ``v_state`` across branches at the tied (d, t) cells. + + This fixture authors NO unit commitment (no ``online_method`` / + ``startup_method`` in ``stochastics.json``), so ``v_online_integer`` never + exists — the variable NA actually ties here is ``v_state`` (storage params + ARE present). The storage non-anticipativity family pins the per-(n, t) + net charge across siblings, which is LP-equivalent to + ``v_state[n, d, t] == v_state[n, b, t]`` at every tied timestep. + + Branches legitimately DIVERGE at non-tied steps, so the cross-branch + equality is restricted to the tied timesteps carried by that roll's + ``fd.dt_non_anticipativity``. At those timesteps the spread of + ``v_state`` across the branch-period axis must collapse to ~0; the test + also asserts that a positive number of tied cells was actually checked so + the witness can never be silently vacuous. + """ + steps = _steps(rolling_result) + tol = 1e-6 + checked = 0 + for name, s in steps.items(): + sol = s.solution + assert sol is not None, f"roll {name!r} retained no solution" + if "v_state" not in sol._vars: + continue + + fd = s.flex_data + assert fd is not None, f"roll {name!r} retained no flex_data" + dtna = fd.dt_non_anticipativity + if dtna is None or dtna.height == 0: + continue + + vs = sol.value("v_state") + cols = vs.columns + ncol = _pick(cols, "n", "node") + tcol = _pick(cols, "t", "time") + + # Restrict to the tied timesteps (the (d, t) tie set's ``t`` axis). + # ``v_state``'s ``d`` is the branch-period axis, so keeping every + # branch at the tied timesteps is what lets the cross-branch spread + # be witnessed; cast ``t`` to Utf8 on both sides to join across the + # Enum vocabularies. + tied_t = (dtna.select(pl.col("t").cast(pl.Utf8).alias("__t")) + .unique()) + vs_tied = (vs.with_columns(pl.col(tcol).cast(pl.Utf8).alias("__t")) + .join(tied_t, on="__t", how="inner")) + if vs_tied.height == 0: + continue + + # Spread of v_state across the branch-period axis at each (n, t). + spread = (vs_tied + .group_by([ncol, "__t"]) + .agg( + n_branches=pl.col("value").len(), + vmax=pl.col("value").max(), + vmin=pl.col("value").min(), + ) + .filter(pl.col("n_branches") > 1)) + for row in spread.iter_rows(named=True): + assert row["vmax"] - row["vmin"] <= tol + 1e-6 * max( + 1.0, abs(row["vmax"]), abs(row["vmin"]) + ), ( + f"roll {name!r}: v_state spread " + f"{row['vmax'] - row['vmin']} at (n={row[ncol]}, " + f"t={row['__t']}) exceeds tol across branches" + ) + checked += 1 + + assert checked > 0, ( + "no tied (n, t) v_state cells with >1 branch were checked — the " + "storage NA witness would be vacuous" + ) From 403f4b5a33f626d7be59b598a3607a963f9b8bcb Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 5 Oct 2026 15:33:52 +0300 Subject: [PATCH 64/87] fix(export): follow v74 rename in export_settings split_params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v74 fold (6c2a24ff) renamed the user-facing solve params non_anticipativity_periods -> shared_operation_periods and non_anticipativity_invest_periods -> shared_invest_periods in the schema, but export_settings.yaml still listed the old names. This failed the TestSplitParamsSchemaSync guards (schema param missing from yaml + yaml param stale vs schema) — the CLAUDE.md invariant #4 post-rename follow-up. Rename both occurrences (solve_period split group + nested_periodic type map) to the user-facing shared_* names. Internal mechanism names stay non_anticipativity_* by design (_solve_config.py, db_migration.py). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/export_to_tabular/export_settings.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 3022b4727..183803f9c 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -89,7 +89,7 @@ split_params: solve_period: params: ["period_timeset", "years_represented", "realized_periods", "invest_periods", "realized_invest_periods", "fix_storage_periods", - "non_anticipativity_periods", "non_anticipativity_invest_periods"] + "shared_operation_periods", "shared_invest_periods"] rolling_parameters: params: ["rolling_solve_jump", "rolling_solve_horizon", "rolling_duration"] solve_s_stochastic_branches: @@ -296,8 +296,8 @@ data_type_overrides: realized_invest_periods: "string (2d-map)" realized_periods: "string (2d-map)" fix_storage_periods: "string (2d-map)" - non_anticipativity_periods: "string (2d-map)" - non_anticipativity_invest_periods: "string (2d-map)" + shared_operation_periods: "string (2d-map)" + shared_invest_periods: "string (2d-map)" # Parameters that are ONLY period-indexed (never time-indexed). # Used to filter them out of _t sheets. Derived from parameter descriptions. From 6a2d219bcc4278ed324c9fd5212a62a3db946b1d Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 5 Oct 2026 16:13:29 +0300 Subject: [PATCH 65/87] fix(stochastic): triggering-cohort branch-reveal validation (W1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the E0 validation in create_stochastic_periods that crashed the rolling stochastic UC. The old code validated EVERY stochastic_branches row against the solve's first step and counted realized rows grouped by period NAME — in rolling, each roll sees the full row set (all 7 roll-boundary reveals under one period), so both checks fired (non-first-step rows + 7 realized across rolls) and every roll after the first crashed with FlexToolConfigError. Uncaught by tests (spec stochastics_reassessment.md §A.1). New rule: validate only the reveal that actually TRIGGERS this solve — the first declared start_step reachable while walking this solve's sliced active_time_list — matching the fan trigger loop so validation and fan agree on the cohort. For that cohort: (i) it must sit at its period's first step in this window (period-boundary reveal); (ii) exactly one realized:yes. This admits both fan-at-first-per-roll (Model 2) and mid-horizon period-boundary reveals (Model 1) without reintroducing Option-B's "first period can't branch" guard, and preserves single-solve fan-at-first byte-for-byte. Fix 1: a pre-reveal rolling roll (no reveal reachable in its window) stays a deterministic trunk instead of false-raising; the "declared branches never fire" safety-net raise is scoped to single solves, detected structurally via solve_modes[complete_solves[solve]] == "rolling_window" (getattr defaults to single, the safe direction). Fix 2: rolling vs single strictness split. A rolling roll sees only its slice, so a declared step that is another roll's legitimate trigger is indistinguishable from a misaligned one — each reveal is validated in the roll where it triggers (documented limitation). A SINGLE solve sees the whole reveal picture, so every declared reveal is validated strictly (each at its period's first step; one realized:yes per period) — restoring pre-W1 per-row strictness and the single-solve midperiod-reveal rejection. Adds 6 focused unit cases (pre-reveal-no-raise, fan-at-first, not-at-boundary, two/zero realized, single-no-reveal-raises). Empirically: all 7 rolls now solve optimally, but per-roll NA frames (period_branch_full / dt_non_anticipativity) are still None — NA coupling in rolling is W3. The coverage test's two NA assertions are therefore xfail(strict, raises=AssertionError, reason="W3 pending") and test_rolling_stochastic_solve_completes now asserts for real. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_stochastic.py | 183 ++++++++++++++---- ..._create_stochastic_periods_continuation.py | 134 ++++++++++++- .../test_rolling_stochastic_na.py | 106 +++++----- 3 files changed, 329 insertions(+), 94 deletions(-) diff --git a/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index 0814287bf..a4b026c66 100644 --- a/flextool/engine_polars/_stochastic.py +++ b/flextool/engine_polars/_stochastic.py @@ -413,9 +413,13 @@ def create_stochastic_periods( realized_time_lists, branch_start_time_lists)``. Raises: - FlexToolConfigError: When a solve's start time isn't in any - ``stochastic_branches`` row, or when more than one - ``realized: yes`` row matches a period. + FlexToolConfigError: When the reveal that triggers a solve does + not start at its branching period's first step (not a + period-boundary reveal), when that triggering cohort does + not carry exactly one ``realized: yes`` row, or when a + single (non-rolling) solve declares ``stochastic_branches`` + none of which reveal at a reachable step of its active time + (silently-ignored branches). """ period__branch_lists: defaultdict[str, list] = defaultdict(list) solve_branch__time_branch_lists: defaultdict[str, list] = defaultdict(list) @@ -440,56 +444,157 @@ def create_stochastic_periods( active_branches: list[str] = [] branch_start_time_lists[solve] = None - # Validate the stochastic_branches rows as a WELL-FORMED - # (possibly mid-horizon) reveal (Slice E design §3.3). Two - # checks replace the old "realized row at the solve's first - # step" guard while preserving fan-at-first-step behaviour - # byte-for-byte: - # (1) every row must start at the FIRST step of the period - # it names — investment is period-granular, so a reveal - # is a PERIOD BOUNDARY (§3.1); a mid-period start_step - # is ill-defined. - # (2) each branching period must carry EXACTLY ONE - # realized:yes branch. For fan-at-first-step this - # reproduces the old found_start guarantee (one realized - # row at the solve start); it additionally rejects a - # malformed reveal with zero or two realized rows in a - # period. - # Because pre-reveal periods author NO stochastic_branches - # rows, the trigger loop below simply keeps them as real-named - # trunk periods (Option B — non-anticipativity by - # construction); no trigger-body change is needed for - # mid-horizon. - first_step_of = { - p: at[0].timestep for p, at in active_time_list.items() - } + # ---------------------------------------------------------- + # Triggering-cohort validation (W1 — spec §A.1/§A.2/§F-step2/ + # §H.3; replaces the old "every row at its period's first step" + # + "one realized per period NAME" pair, which crashed rolling). + # + # A solve is triggered by exactly ONE reveal: the FIRST declared + # ``start_step`` reachable while walking THIS solve's (already + # period/roll-sliced) ``active_time_list`` in order (period, + # then step) — the same walk the trigger loop below performs. + # ``info`` is read per COMPLETE solve, so for a rolling parent + # every roll sees ALL rolls' reveal rows; only the row that + # triggers THIS roll is validatable here (the rest are other + # rolls' triggers, each validated when it is its own roll's + # trigger). We therefore validate the triggering cohort only + # and tolerate the non-triggering rows: + # (i) the triggering ``start_step`` must be the FIRST step of + # its period in this solve's window — investment is + # period-granular, so a reveal is a PERIOD BOUNDARY; this + # admits BOTH Model-1 mid-horizon reveals and Model-2 + # fan-at-first (and does NOT re-introduce Option-B's + # "first period can't branch" guard — see Appendix). + # (ii) the triggering cohort {rows sharing that (period, + # start_step)} must carry EXACTLY ONE realized:yes row. + # When no reveal is reachable in this solve's window the solve + # stays a deterministic trunk (``branched`` remains False, so + # ``branch_start_time_lists[solve]`` stays None and the walk + # below copies every period as-is). That is correct for a + # rolling PRE-REVEAL roll; but for a SINGLE (non-rolling) solve + # a non-empty ``info`` with no reachable reveal means the + # branches would be silently ignored → misconfiguration, raise. + # Rolling vs single is detected structurally: a rolling roll's + # complete-solve carries ``solve_mode == "rolling_window"`` (set + # in RecursiveSolveBuilder), not by sniffing the ``_roll_N`` + # name suffix. + # + # Fix 2 (non-triggering malformed reveals): a clean cross-roll + # strict check is NOT feasible at this per-roll call site. Each + # roll sees only its own sliced window, and a declared step that + # is some OTHER roll's legitimate trigger is indistinguishable + # here from a jump-misaligned garbage step. A cross-roll + # pre-pass keyed on roll first-steps is no help either — it + # would false-reject a legitimate Model-1 mid-horizon reveal + # (which is a PERIOD boundary, not a roll boundary). So each + # reveal is validated in the roll where it triggers; this is the + # documented limitation for the rolling case. For a SINGLE + # (non-rolling) solve the WHOLE reveal picture is visible in the + # one window, so every declared reveal is validated strictly — + # each must sit at its period's first step and each branching + # period must carry exactly one realized:yes — restoring the + # pre-W1 per-row strictness (a mid-period or extra reveal in a + # single solve is a real authoring error, not another roll's + # reveal). This raises, matching the pre-W1 behaviour. + # ---------------------------------------------------------- + is_rolling_sub_solve = ( + getattr( + getattr(self.state, "solve", None), "solve_modes", {} + ).get(complete_solves[solve], "single_solve") + == "rolling_window" + ) + + declared_start_steps: defaultdict[str, set] = defaultdict(set) for row in info: - if row[0] not in first_step_of or row[2] != first_step_of[row[0]]: + declared_start_steps[row[0]].add(row[2]) + + trig_period: str | None = None + trig_step: str | None = None + for period_w, active_time_w in active_time_list.items(): + if period_w in declared_start_steps: + for step_w in active_time_w: + if step_w.timestep in declared_start_steps[period_w]: + trig_period = period_w + trig_step = step_w.timestep + break + if trig_period is not None: + break + + if trig_period is None: + if info and not is_rolling_sub_solve: + message = ( + "A single (non-rolling) stochastic solve declares " + "stochastic_branches but none reveal at a reachable " + "step of its active time — the branches would be " + "silently ignored. Check stochastic_branches " + f"start_step values against solve {solve!r}'s " + "timeline." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + else: + if trig_step != active_time_list[trig_period][0].timestep: message = ( "A stochastic branch must start at the FIRST step of " - "its branching period (period-boundary reveal). " - f"Branch {row[1]!r} of period {row[0]!r} declares " - f"start_step {row[2]!r}, which is not that period's " - "first step. Check stochastic_branches." + "its branching period (period-boundary reveal). The " + f"reveal triggering solve {solve!r} is period " + f"{trig_period!r} start_step {trig_step!r}, which is " + "not that period's first step in this solve. Check " + "stochastic_branches." ) self.logger.error(message) raise FlexToolConfigError(message) - by_period: defaultdict[str, list] = defaultdict(list) - for row in info: - by_period[row[0]].append(row) - for period_k, rows_k in by_period.items(): - n_real = sum(1 for r in rows_k if r[3] == "yes") + n_real = sum( + 1 + for r in info + if r[0] == trig_period and r[2] == trig_step and r[3] == "yes" + ) if n_real != 1: message = ( - f"stochastic branching period {period_k!r} must have " + "The triggering stochastic branching cohort (period " + f"{trig_period!r}, start_step {trig_step!r}) must have " f"exactly one realized:yes branch; found {n_real}. " "Check stochastic_branches (one realized branch per " - "branching period; start_step at the period's first " - "step)." + "reveal; start_step at the period's first step)." ) self.logger.error(message) raise FlexToolConfigError(message) + if not is_rolling_sub_solve: + # Single solve: validate EVERY declared reveal, not just + # the triggering cohort — the whole picture is visible, so + # a mid-period or mis-realized reveal here is a real + # authoring error (pre-W1 per-row strictness). + for period_k in declared_start_steps: + if period_k not in active_time_list: + continue + period_first_k = active_time_list[period_k][0].timestep + for step_k in sorted(declared_start_steps[period_k]): + if step_k != period_first_k: + message = ( + "A stochastic branch must start at the " + "FIRST step of its branching period " + "(period-boundary reveal). Solve " + f"{solve!r} declares period {period_k!r} " + f"start_step {step_k!r}, which is not that " + "period's first step. Check " + "stochastic_branches." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + n_real_k = sum( + 1 for r in info + if r[0] == period_k and r[3] == "yes" + ) + if n_real_k != 1: + message = ( + f"Stochastic branching period {period_k!r} " + "must have exactly one realized:yes branch; " + f"found {n_real_k}. Check stochastic_branches." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + # Walk periods — at the first branch trigger, fan out into # branches; subsequent periods continue the same branches. # NB: the reference implementation's continuation block diff --git a/tests/engine_polars/test_create_stochastic_periods_continuation.py b/tests/engine_polars/test_create_stochastic_periods_continuation.py index 36875722c..d032a45ec 100644 --- a/tests/engine_polars/test_create_stochastic_periods_continuation.py +++ b/tests/engine_polars/test_create_stochastic_periods_continuation.py @@ -17,7 +17,9 @@ from collections import defaultdict from types import SimpleNamespace -from flextool.engine_polars._solve_state import ActiveTimeEntry +import pytest + +from flextool.engine_polars._solve_state import ActiveTimeEntry, FlexToolConfigError from flextool.engine_polars._stochastic import StochasticSolver @@ -192,6 +194,136 @@ def test_zero_weight_branch_metadata_only() -> None: ] +# --------------------------------------------------------------------------- +# W1 — triggering-cohort validation (spec §A.1/§A.2/§F-step2/§H.3). +# +# The shipped ``1_week_rolling_wind`` fixture fans at EVERY roll, so it never +# exercises the pre-reveal-roll branch of Fix 1. These solver-free cases cover +# it (plus the reject paths) directly. Rolling vs single is detected +# structurally via ``state.solve.solve_modes[complete_solve] == "rolling_window"`` +# (not the ``_roll_N`` name suffix); so the state stub here grows a ``solve`` +# namespace carrying that map. +# --------------------------------------------------------------------------- + +# A single-period roll window of three steps (period p1). +ROLL_WIN = _ats([("t0001", 0), ("t0002", 1), ("t0003", 2)]) + + +def _make_solver_with_modes(solve_modes: dict) -> StochasticSolver: + state = SimpleNamespace( + logger=logging.getLogger("test_create_stochastic_periods"), + timeline=SimpleNamespace(stochastic_timesteps=defaultdict(list)), + solve=SimpleNamespace(solve_modes=dict(solve_modes)), + ) + return StochasticSolver(state) + + +def _run_one( + solver, info, active, *, solve="s", complete=None, realized=None, + fix_storage=None, +): + complete = solve if complete is None else complete + return solver.create_stochastic_periods( + {complete: info}, + [solve], + {solve: complete}, + {solve: active}, + {solve: fix_storage or {}}, + {solve: realized or {}}, + ) + + +def test_w1_rolling_pre_reveal_roll_does_not_raise() -> None: + """Fix 1: a rolling roll whose sliced window legitimately PRECEDES the + first reveal (reveal declared at a later roll's first step, unreachable + here) stays a deterministic trunk — no raise, no branching.""" + solver = _make_solver_with_modes({"roll_c": "rolling_window"}) + # Reveal declared at t0010 (a later roll's first step); this roll's window + # only reaches t0001..t0003, so no reveal is reachable. + info = [ + ("p1", "rlz", "t0010", "yes", "1"), + ("p1", "low", "t0010", "no", "3"), + ] + (pb, sbtb, atl, jumps, fstl, rtl, bstl) = _run_one( + solver, info, {"p1": ROLL_WIN}, + solve="roll_c_roll_0", complete="roll_c", + ) + # Deterministic trunk: self-row only, no fan, no branch start. + assert pb["roll_c_roll_0"] == [("p1", "p1")] + assert sbtb["roll_c_roll_0"] == [] + assert bstl["roll_c_roll_0"] is None + assert list(atl["roll_c_roll_0"].keys()) == ["p1"] + + +def test_w1_rolling_fan_at_first_passes() -> None: + """Fix 1: a rolling roll whose reveal triggers at the roll's first step + (fan-at-first per roll, Model 2) passes and fans out.""" + solver = _make_solver_with_modes({"roll_c": "rolling_window"}) + info = [ + ("p1", "rlz", "t0001", "yes", "1"), + ("p1", "low", "t0001", "no", "3"), + ] + (pb, sbtb, atl, jumps, fstl, rtl, bstl) = _run_one( + solver, info, {"p1": ROLL_WIN}, + solve="roll_c_roll_0", complete="roll_c", + ) + assert bstl["roll_c_roll_0"] == ("p1", "t0001") + assert pb["roll_c_roll_0"] == [ + ("p1", "p1"), + ("p1", "p1_rlz"), + ("p1", "p1_low"), + ] + assert sbtb["roll_c_roll_0"] == [("p1_low", "low"), ("p1", "rlz")] + + +def test_w1_trigger_not_at_period_first_step_raises() -> None: + """Reject: the triggering reveal's start_step is not the period's first + step in this solve (not a period-boundary reveal).""" + solver = _make_solver_with_modes({}) # single solve + # Reveal declared at t0002, but the window's first step is t0001. + info = [ + ("p1", "rlz", "t0002", "yes", "1"), + ("p1", "low", "t0002", "no", "3"), + ] + with pytest.raises(FlexToolConfigError, match="FIRST step"): + _run_one(solver, info, {"p1": ROLL_WIN}) + + +def test_w1_trigger_cohort_two_realized_raises() -> None: + """Reject: the triggering cohort carries two realized:yes rows.""" + solver = _make_solver_with_modes({}) + info = [ + ("p1", "rlz1", "t0001", "yes", "1"), + ("p1", "rlz2", "t0001", "yes", "1"), + ("p1", "low", "t0001", "no", "3"), + ] + with pytest.raises(FlexToolConfigError, match="exactly one realized"): + _run_one(solver, info, {"p1": ROLL_WIN}) + + +def test_w1_trigger_cohort_zero_realized_raises() -> None: + """Reject: the triggering cohort carries no realized:yes row.""" + solver = _make_solver_with_modes({}) + info = [ + ("p1", "low", "t0001", "no", "3"), + ("p1", "high", "t0001", "no", "3"), + ] + with pytest.raises(FlexToolConfigError, match="exactly one realized"): + _run_one(solver, info, {"p1": ROLL_WIN}) + + +def test_w1_single_solve_no_reachable_reveal_raises() -> None: + """Safety net preserved: a SINGLE (non-rolling) solve with non-empty + info but no reachable reveal raises (silently-ignored branches).""" + solver = _make_solver_with_modes({}) # single solve + info = [ + ("p1", "rlz", "t0099", "yes", "1"), + ("p1", "low", "t0099", "no", "3"), + ] + with pytest.raises(FlexToolConfigError, match="silently ignored"): + _run_one(solver, info, {"p1": ROLL_WIN}) + + def test_deterministic_solve_passthrough() -> None: """No stochastic rows → every period keeps its time lists and gets a (period, period) self-row; no branches anywhere (byte-parity diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py index 49b44b53d..e282183e6 100644 --- a/tests/engine_polars/test_rolling_stochastic_na.py +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -11,18 +11,22 @@ Why this test exists / what it protects --------------------------------------- -Today the rolling solve CRASHES during E0 branch-reveal validation -(``flextool/engine_polars/_stochastic.py:466-476`` raises -``FlexToolConfigError`` — "a stochastic branch must start at the FIRST step -of its branching period"). This is the W1/W3 defect tracked in -``specs/stochastics_reassessment.md`` §A.1 / §A.2 / §E / §F. - -This file lands BEFORE that fix. It keeps the suite GREEN now by documenting -the crash as a strict xfail (``raises=FlexToolConfigError``) and flips to real -pass-assertions with a SINGLE one-line change (``W1_W3_LANDED = True``) once -W1 + W3 merge. Until then every test memoises and re-raises the one -``FlexToolConfigError`` so pytest reports ``xfailed`` (never ``failed`` / -``xpassed``). +This file was written BEFORE W1 as a coverage-first guard. Before W1 the +rolling solve CRASHED during E0 branch-reveal validation +(``flextool/engine_polars/_stochastic.py`` raised ``FlexToolConfigError`` — +"a stochastic branch must start at the FIRST step of its branching period"). +This is the W1/W3 defect tracked in ``specs/stochastics_reassessment.md`` +§A.1 / §A.2 / §E / §F. + +W1 has now landed: the triggering-cohort validation replaces that first-step +guard, so the rolling solve runs to a strict optimum on every roll and +``test_rolling_stochastic_solve_completes`` asserts for real. W3 (per-roll NA +frames) has NOT landed — ``period_branch_full`` / ``dt_non_anticipativity`` +are still ``None`` on every roll, so no non-anticipativity constraint fires in +rolling yet. The two NA-coupling tests are ``xfail(strict=True, +raises=AssertionError)`` until W3 populates those frames; remove their +decorators (and reconcile the review advisories in their docstrings against the +real per-roll frames) once W3 merges. The structural signal the tests assert post-flip is the exact precondition the operational-NA gate (``model.py:4519-4522``) requires to emit the four @@ -50,51 +54,33 @@ # rolling_solve_jump = 24 → 7 rolls. EXPECTED_ROLLS = 7 -# >>> ONE-LINE FLIP: set True once W1 + W3 are merged. <<< +# W1 HAS LANDED (triggering-cohort validation replaces the E0 first-step-only +# guard) — the rolling stochastic solve no longer crashes and every roll now +# reaches a strict optimum, so ``test_rolling_stochastic_solve_completes`` +# asserts for real (module-level xfail removed). # -# Before flipping, reconcile the POST-FLIP assertions in -# ``test_rolling_na_ties_storage_across_branches`` against the real per-roll NA -# frames (they cannot be observed until W1/W3 exist — auto-memory -# "Rolling stochastic broken" records that the current stack drops all NA -# coupling in rolling). Two review advisories: -# (1) The storage-NA family ties per-(n,t) NET CHARGE (a delta), not absolute -# ``v_state`` (model.py:4505). Absolute-state equality holds only when the -# tied window is a prefix from a shared initial state; a mid-horizon roll -# with a per-branch-free initial state can satisfy the net-charge tie with -# a constant ``v_state`` offset. If so, assert on the tied net-charge LHS, -# not absolute ``v_state``. -# (2) This fixture reveals multiple times within one 96-step horizon, so tie -# windows differ per reveal. Restrict the cross-branch comparison to the -# exact tied (d, t) PAIRS, not just the union of tied ``t`` across all -# branch-periods, or a legitimately-diverged branch gets compared against a -# still-tied one. -# The PRIMARY structural signal (test_rolling_na_frames_couple_branches_per_roll) -# is unaffected by both and is the load-bearing coverage assertion. -W1_W3_LANDED = False - -pytestmark = [ - pytest.mark.solver, - pytest.mark.xfail( - not W1_W3_LANDED, - strict=True, - raises=FlexToolConfigError, - reason="E0 validation crashes rolling stochastic UC " - "(flextool/engine_polars/_stochastic.py:466-476); " - "W1/W3 land after this coverage test — spec §A.1/§E/§F.", - ), -] +# W3 (per-roll NA frames) has NOT landed: ``apply_branch_cluster`` still does +# not populate ``period_branch_full`` / ``dt_non_anticipativity`` per roll +# (both come back ``None`` on every roll — confirmed empirically under the W1 +# change), so NO non-anticipativity constraints fire in rolling yet. The two +# NA-coupling assertions are therefore marked ``xfail(strict=True, +# raises=AssertionError)`` until W3 populates those frames. When W3 lands the +# two decorators below come off (and the review advisories in each NA test's +# docstring must be reconciled against the real per-roll frames first). +pytestmark = [pytest.mark.solver] @pytest.fixture(scope="module") def rolling_result(stochastic_db_url, test_solver_config_dir, tmp_path_factory): """Run the rolling stochastic scenario end-to-end ONCE for the module. - Memoises either the per-step dict (post-fix) or the - ``FlexToolConfigError`` the E0 validation raises today, so each test can - re-raise it in its own body and report a strict xfail. The - ``run_chain_from_db`` call runs under ``FLEXTOOL_AUTOSCALE_STRICT=1`` so - that any future autoscale-registry gap in the NA path is loud rather than - silently reverting to an un-scaled LP (invariant #1). + Post-W1 the solve runs to completion, so this memoises the per-step dict. + The ``except FlexToolConfigError`` is a defensive guard: if a future change + re-breaks branch-reveal validation, the crash is memoised and re-raised in + each test body (surfacing as a plain failure now that the module-level + xfail is gone). The ``run_chain_from_db`` call runs under + ``FLEXTOOL_AUTOSCALE_STRICT=1`` so that any autoscale-registry gap in the NA + path is loud rather than silently reverting to an un-scaled LP (invariant #1). """ work_folder = tmp_path_factory.mktemp("rolling_stochastic_na") prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") @@ -119,11 +105,11 @@ def rolling_result(stochastic_db_url, test_solver_config_dir, tmp_path_factory): def _steps(rolling_result): - """Return the per-step dict, or re-raise the memoised E0 crash. + """Return the per-step ``{solve_name: OrchestrationStep}`` dict. - Pre-fix this re-raises ``FlexToolConfigError`` inside the test body, which - the module-level strict xfail converts to ``xfailed``. Post-fix it hands - back the live ``{solve_name: OrchestrationStep}`` dict. + Re-raises the memoised ``FlexToolConfigError`` only in the defensive case + where branch-reveal validation has re-broken (see ``rolling_result``); on + the normal post-W1 path ``error`` is ``None`` and the dict is returned. """ if rolling_result["error"] is not None: raise rolling_result["error"] @@ -149,6 +135,12 @@ def test_rolling_stochastic_solve_completes(rolling_result): } +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="W3: per-roll NA frames (period_branch_full / " + "dt_non_anticipativity) not yet populated in rolling.", +) def test_rolling_na_frames_couple_branches_per_roll(rolling_result): """PRIMARY structural signal: each roll carries the NA precondition. @@ -189,6 +181,12 @@ def test_rolling_na_frames_couple_branches_per_roll(rolling_result): ) +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="W3: per-roll NA frames (period_branch_full / " + "dt_non_anticipativity) not yet populated in rolling.", +) def test_rolling_na_ties_storage_across_branches(rolling_result): """Storage NA ties ``v_state`` across branches at the tied (d, t) cells. From 79dce7e4c7009baefc6920f21968bd873438ee85 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 6 Oct 2026 00:02:44 +0300 Subject: [PATCH 66/87] fix(stochastic): populate per-roll non-anticipativity frames (W3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After W1 the rolling stochastic solve ran but NA never coupled branches: per roll, flex_data.period_branch_full and dt_non_anticipativity came back None, so the operational-NA gate (model.py:4519-4522) never fired. Root cause: rolling sub-solves (1week_rolling_roll_N) are not declared in Spine, so the synthetic-solve early-return in _apply_db_overrides fires and skips passes 3-10 — including apply_derived_g -> apply_branch_cluster, the sole producer of those two frames. The per-roll emit was already correct (period__branch.csv with the d!=b fan rows; dt_non_anticipativity_set.csv = the 24-step jump). Fix: an NA-1 re-wire in the synthetic-solve branch (alongside the existing RESERVE-1/SECTION-1/MINLOAD-1/INFLOW-1 re-wires) that populates ONLY period_branch_full and dt_non_anticipativity, replicating apply_branch_cluster's two seed-preserving assignments verbatim. It deliberately does NOT call the full apply_branch_cluster, which would overwrite the correctly-seeded period_in_use_set via an unsorted .unique() and reorder nested-invest set outputs (byte-parity regression caught in review). Non-stochastic synthetic solves stay unaffected: with no d!=b fan rows, period_branch_full is self-rows only -> db_pairs.height==0 -> the gate's inner guard skips the constraint. tie = jump (dt_non_anticipativity = the committed jump, naturally). The storage NA family ties NET CHARGE (per-(n,t) delta), not absolute v_state, so the coverage test's storage assertion is rewritten to witness the net-charge delta equality across the branch cohort (incl. the realized trunk) at tied timesteps; both NA-coupling tests' xfail decorators are removed and now assert for real. Deferred (NOT W3): absolute storage continuity (spec §B/§D.6) — forecast branches cyclic-self-link their storage start while the realized trunk is start-anchored, giving a constant realized<->forecast offset; closing it is a separate anchor-repointing slice. Also flagged: period_branch_full now also feeds the invest handoff guards and benders is_genuinely_stochastic per roll — intended single-solve parity, untested for rolling-stochastic-invest (follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/input.py | 52 ++++++ .../test_rolling_stochastic_na.py | 159 +++++++++++------- 2 files changed, 148 insertions(+), 63 deletions(-) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 0e570c777..72f8a0b87 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5053,6 +5053,58 @@ def _wire_inflow_split_for_synthetic_solve(): _drv.pdtNodeInflow_per_step_from_inflow(p_inflow_csv, sd_csv)) _loadflex_prof("apply_db_overrides:pass_synthetic_inflow") _timed("c.synth inflow", _wire_inflow_split_for_synthetic_solve) + + # NA-1 — same class of gap as RESERVE-1 / SECTION-1 / MINLOAD-1 / + # INFLOW-1. The synthetic-solve early-return skips passes 3-10, so + # ``apply_derived_g``'s ``apply_branch_cluster`` — the sole producer + # of ``period_branch_full`` / ``dt_non_anticipativity`` — never runs + # on a rolling sub-solve (each ``_rolling_roll_N`` names a solve + # absent from Spine). Both frames keep their deliberately-None seeds + # (input.py:3844-3845), so the non-anticipativity gate + # (``model.py`` ``_add_non_anticipativity_constraints``) never fires + # and stochastic branches stay uncoupled across the rolling solve. + # The two ``*_df`` readers are solve-agnostic — they unroll the + # per-roll workdir CSVs (``period__branch.csv`` / + # ``dt_non_anticipativity_set.csv``) which the per-roll emit already + # writes correctly — and both return ``None`` (height==0) for a + # non-stochastic roll. We replicate ONLY ``apply_branch_cluster``'s + # two frame assignments verbatim (seed-preserving overlay, + # ``_derived_branch`` lines ~1655-1664) and deliberately do NOT call + # the full helper: it would overwrite the already-correctly-seeded + # ``period_in_use_set`` via an unsorted ``.unique()`` and reorder + # nested-invest set outputs. Safe for non-stochastic synthetic + # solves: with no d≠b fan rows ``period_branch_full`` yields only + # self-rows → ``db_pairs.height==0`` → the gate's inner d≠b guard + # skips the constraint even though ``dt_non_anticipativity`` is + # non-empty. See ``specs/model_bugs.md::NA-1``. + # + # NOTE (consumer surface): ``period_branch_full`` also feeds + # ``synthetic_branch_tokens`` in the invest handoff guards + # (``build_handoff_from_solution`` Slice C/D) and + # ``is_genuinely_stochastic`` (benders). Before this wire those were + # inert per roll (frame was ``None``); on a rolling-stochastic solve + # WITH investment or benders they now activate — intended parity with + # the single-solve path, but not yet exercised by a fixture (the + # stochastics fixture has no invest/benders). A rolling-stochastic- + # invest test is the follow-up slice. + def _wire_non_anticipativity_for_synthetic_solve(): + from flextool.engine_polars._derived_branch import ( + dt_non_anticipativity_df, + period_branch_full_df, + ) + _new_pbf = period_branch_full_df(workdir_path, provider=provider) + if _new_pbf is not None or getattr( + flex_data, "period_branch_full", None) is None: + flex_data.period_branch_full = _new_pbf + _new_dtn = dt_non_anticipativity_df( + workdir_path, provider=provider) + if _new_dtn is not None or getattr( + flex_data, "dt_non_anticipativity", None) is None: + flex_data.dt_non_anticipativity = _new_dtn + _loadflex_prof( + "apply_db_overrides:pass_synthetic_non_anticipativity") + _timed("c.synth non_anticipativity", + _wire_non_anticipativity_for_synthetic_solve) return _loadflex_prof("apply_db_overrides:pass_derived_a") diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py index e282183e6..610d1104d 100644 --- a/tests/engine_polars/test_rolling_stochastic_na.py +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -20,23 +20,36 @@ W1 has now landed: the triggering-cohort validation replaces that first-step guard, so the rolling solve runs to a strict optimum on every roll and -``test_rolling_stochastic_solve_completes`` asserts for real. W3 (per-roll NA -frames) has NOT landed — ``period_branch_full`` / ``dt_non_anticipativity`` -are still ``None`` on every roll, so no non-anticipativity constraint fires in -rolling yet. The two NA-coupling tests are ``xfail(strict=True, -raises=AssertionError)`` until W3 populates those frames; remove their -decorators (and reconcile the review advisories in their docstrings against the -real per-roll frames) once W3 merges. - -The structural signal the tests assert post-flip is the exact precondition -the operational-NA gate (``model.py:4519-4522``) requires to emit the four +``test_rolling_stochastic_solve_completes`` asserts for real. + +W3 HAS NOW LANDED too. Root cause: the synthetic-solve early-return in +``_apply_db_overrides`` (``flextool/engine_polars/input.py``) fires for every +rolling sub-solve (``1week_rolling_roll_N`` is not declared in Spine), so +passes 3-10 — including ``apply_derived_g`` → ``apply_branch_cluster``, the +sole producer of ``period_branch_full`` / ``dt_non_anticipativity`` — never +ran per roll and both frames kept their deliberately-None seeds. The fix (the +``NA-1`` re-wire, alongside the existing RESERVE-1/SECTION-1/MINLOAD-1/INFLOW-1 +re-wires) populates ONLY those two frames on the synthetic path by replicating +``apply_branch_cluster``'s two frame assignments verbatim — it deliberately +does NOT call the full helper, which would overwrite the correctly-seeded +``period_in_use_set`` via an unsorted ``.unique()`` and reorder nested-invest +set outputs. So the two NA-coupling tests below assert for real (xfail +decorators removed). + +Deferred gap (NOT delivered by W3): absolute storage continuity — forecast +branches continuing from the committed roll-start state (spec §B / §D.6). The +storage NA constraint ties NET CHARGE (per-(n, t) charge delta, +``model.py:219-234``), not absolute ``v_state``. The realized trunk +``period1`` is start-anchored while the three forecast copies cyclic-self-link +their storage start, so absolute ``v_state`` carries a constant realized↔ +forecast offset (~0.06 here) even though the net-charge deltas are tied to +~1e-16. Closing that offset is a separate anchor-repointing slice. + +The structural signal the frames test asserts is the exact precondition the +operational-NA gate (``model.py:4519-4522``) requires to emit the four ``non_anticipativity_*`` constraint families: a per-roll ``flex_data`` whose ``period_branch_full`` (with a non-empty ``d != b`` sub-cohort) and -``dt_non_anticipativity`` frames are both populated. Those frames are -produced authoritatively by ``apply_branch_cluster`` within -``apply_derived_g`` (``flextool/engine_polars/input.py:3842-3845``) — the -``_derived_branch.py`` overlay is only a seed-preserving pass, not the -producer. +``dt_non_anticipativity`` frames are both populated. """ from __future__ import annotations @@ -59,14 +72,11 @@ # reaches a strict optimum, so ``test_rolling_stochastic_solve_completes`` # asserts for real (module-level xfail removed). # -# W3 (per-roll NA frames) has NOT landed: ``apply_branch_cluster`` still does -# not populate ``period_branch_full`` / ``dt_non_anticipativity`` per roll -# (both come back ``None`` on every roll — confirmed empirically under the W1 -# change), so NO non-anticipativity constraints fire in rolling yet. The two -# NA-coupling assertions are therefore marked ``xfail(strict=True, -# raises=AssertionError)`` until W3 populates those frames. When W3 lands the -# two decorators below come off (and the review advisories in each NA test's -# docstring must be reconciled against the real per-roll frames first). +# W3 HAS LANDED too: the ``NA-1`` synthetic-path re-wire in +# ``_apply_db_overrides`` now populates ``period_branch_full`` / +# ``dt_non_anticipativity`` per roll, so the operational non-anticipativity +# constraints fire in rolling and both NA-coupling tests below assert for real +# (their xfail decorators have been removed). pytestmark = [pytest.mark.solver] @@ -135,20 +145,14 @@ def test_rolling_stochastic_solve_completes(rolling_result): } -@pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="W3: per-roll NA frames (period_branch_full / " - "dt_non_anticipativity) not yet populated in rolling.", -) def test_rolling_na_frames_couple_branches_per_roll(rolling_result): """PRIMARY structural signal: each roll carries the NA precondition. - ``apply_branch_cluster`` (in ``apply_derived_g``, - ``flextool/engine_polars/input.py:3842-3845``) must populate, on every - roll's ``flex_data``, both the ``period_branch_full`` (d, b) anchor→sibling - map — with a non-empty ``d != b`` sub-cohort — and the - ``dt_non_anticipativity`` (d, t) tie set. That is exactly the gate the + The ``NA-1`` synthetic-path re-wire in ``_apply_db_overrides`` + (replicating ``apply_branch_cluster``'s two frame assignments) must + populate, on every roll's ``flex_data``, both the ``period_branch_full`` + (d, b) anchor→sibling map — with a non-empty ``d != b`` sub-cohort — and + the ``dt_non_anticipativity`` (d, t) tie set. That is exactly the gate the operational-NA families check at ``model.py:4519-4522`` before emitting the ``non_anticipativity_*`` constraints. """ @@ -181,28 +185,37 @@ def test_rolling_na_frames_couple_branches_per_roll(rolling_result): ) -@pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="W3: per-roll NA frames (period_branch_full / " - "dt_non_anticipativity) not yet populated in rolling.", -) def test_rolling_na_ties_storage_across_branches(rolling_result): - """Storage NA ties ``v_state`` across branches at the tied (d, t) cells. + """Storage NA ties NET CHARGE across branches at the tied (d, t) cells. This fixture authors NO unit commitment (no ``online_method`` / ``startup_method`` in ``stochastics.json``), so ``v_online_integer`` never exists — the variable NA actually ties here is ``v_state`` (storage params ARE present). The storage non-anticipativity family pins the per-(n, t) - net charge across siblings, which is LP-equivalent to - ``v_state[n, d, t] == v_state[n, b, t]`` at every tied timestep. + NET CHARGE across siblings — the consecutive ``v_state`` delta + ``v_state[n, d, t] - v_state[n, d, t_prev]`` (``model.py:219-234``) — NOT + absolute ``v_state``. This test therefore witnesses the net charge: for + each branch-period ``d`` in the cohort (``v_state``'s ``d`` axis carries + the realized trunk ``period1`` plus the three forecast copies + ``period1_{lower,mid,upper}``) and node ``n``, it sorts by tied timestep + ``t``, takes the consecutive ``v_state`` delta, and asserts that delta is + equal across the cohort ``d`` at each matching ``(n, t)`` tied step. The + cohort includes the realized ``period1``, so this exercises realized↔ + forecast coupling, not merely forecast↔forecast. + + Absolute storage continuity is explicitly NOT delivered by W3 (spec §B / + §D.6): the realized trunk ``period1`` is start-anchored while the forecast + copies cyclic-self-link their storage start, so absolute ``v_state`` + carries a constant realized↔forecast offset (measured ~0.06 here) even + though the net-charge deltas collapse to ~1e-16. Closing that offset — + forecast branches continuing from the committed roll-start state — is a + separate anchor-repointing slice, not part of W3. Branches legitimately DIVERGE at non-tied steps, so the cross-branch - equality is restricted to the tied timesteps carried by that roll's - ``fd.dt_non_anticipativity``. At those timesteps the spread of - ``v_state`` across the branch-period axis must collapse to ~0; the test - also asserts that a positive number of tied cells was actually checked so - the witness can never be silently vacuous. + net-charge equality is restricted to the tied timesteps carried by that + roll's ``fd.dt_non_anticipativity``. The test also asserts that a + positive number of tied net-charge cells was actually checked so the + witness can never be silently vacuous. """ steps = _steps(rolling_result) tol = 1e-6 @@ -223,39 +236,59 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): cols = vs.columns ncol = _pick(cols, "n", "node") tcol = _pick(cols, "t", "time") + dcol = _pick(cols, "d", "period") # Restrict to the tied timesteps (the (d, t) tie set's ``t`` axis). # ``v_state``'s ``d`` is the branch-period axis, so keeping every - # branch at the tied timesteps is what lets the cross-branch spread - # be witnessed; cast ``t`` to Utf8 on both sides to join across the - # Enum vocabularies. + # branch at the tied timesteps is what lets the cross-branch net + # charge be witnessed; cast every key to Utf8 so the join / grouping + # composes across the Enum vocabularies. tied_t = (dtna.select(pl.col("t").cast(pl.Utf8).alias("__t")) .unique()) - vs_tied = (vs.with_columns(pl.col(tcol).cast(pl.Utf8).alias("__t")) + vs_tied = (vs.with_columns( + pl.col(ncol).cast(pl.Utf8).alias("__n"), + pl.col(dcol).cast(pl.Utf8).alias("__d"), + pl.col(tcol).cast(pl.Utf8).alias("__t"), + ) .join(tied_t, on="__t", how="inner")) if vs_tied.height == 0: continue - # Spread of v_state across the branch-period axis at each (n, t). - spread = (vs_tied - .group_by([ncol, "__t"]) + # Net charge = consecutive v_state delta within each (n, d), over the + # tied timesteps sorted by t. The first tied step per (n, d) has no + # predecessor → null delta, dropped. + # NB: this takes the delta over consecutive TIED steps, which matches + # the constraint's true-predecessor net charge only while the tied set + # is contiguous in real time (it is here — each roll's jump is 24 + # contiguous steps). A future non-contiguous dt_non_anticipativity + # would need the delta taken over real predecessors instead. + deltas = (vs_tied + .sort(["__n", "__d", "__t"]) + .with_columns( + delta=(pl.col("value") - pl.col("value").shift(1)) + .over(["__n", "__d"])) + .drop_nulls("delta")) + + # Spread of net charge across the branch-period axis at each (n, t). + spread = (deltas + .group_by(["__n", "__t"]) .agg( - n_branches=pl.col("value").len(), - vmax=pl.col("value").max(), - vmin=pl.col("value").min(), + n_branches=pl.col("delta").len(), + dmax=pl.col("delta").max(), + dmin=pl.col("delta").min(), ) .filter(pl.col("n_branches") > 1)) for row in spread.iter_rows(named=True): - assert row["vmax"] - row["vmin"] <= tol + 1e-6 * max( - 1.0, abs(row["vmax"]), abs(row["vmin"]) + assert row["dmax"] - row["dmin"] <= tol + 1e-6 * max( + 1.0, abs(row["dmax"]), abs(row["dmin"]) ), ( - f"roll {name!r}: v_state spread " - f"{row['vmax'] - row['vmin']} at (n={row[ncol]}, " + f"roll {name!r}: net-charge spread " + f"{row['dmax'] - row['dmin']} at (n={row['__n']}, " f"t={row['__t']}) exceeds tol across branches" ) checked += 1 assert checked > 0, ( - "no tied (n, t) v_state cells with >1 branch were checked — the " + "no tied (n, t) net-charge cells with >1 branch were checked — the " "storage NA witness would be vacuous" ) From 9921c6aecf47f51af5237cd18b5acd5836ea2437 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 6 Oct 2026 09:31:05 +0300 Subject: [PATCH 67/87] =?UTF-8?q?feat(stochastic):=20storage=20branch-poin?= =?UTF-8?q?t=20continuity=20across=20branches=20(=C2=A7D.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage non-anticipativity family ties NET CHARGE (per-(n,t) flow delta, model.py:219-234), not absolute v_state. In a rolling solve the untied post-reveal tail breaks the telescoping that makes single-solve tie absolutely, so the forecast branch copies floated by a constant v_state offset vs the realized trunk (~0.06 in 1_week_rolling_wind) — i.e. §B/§D.6 branch-point continuity was not delivered (W3 deferred this). Adds a new constraint family non_anticipativity_storage_state_first (block "1b" in _add_non_anticipativity_constraints, mirroring non_anticipativity_online_integer) that pins absolute v_state[n,d,t_first] - v_state[n,b,t_first] == 0 across the branch cohort at the FIRST tied step of each anchor period. Telescoping the existing per-step net-charge ties over the contiguous jump off that absolute anchor closes the whole tied range absolutely; the offset collapses to ~0. It is optimum-conditional on zero balance slack + branch-invariant inflow (documented). v_online/v_reserve need no analog — they already pin the absolute variable at every tied step. Proactive horizon-guard: emit per (n,d) ONLY when the tied set is a strict subset of that node's per-period v_state timeline (an untied tail exists). On a fully-tied solve (single-solve 2day_dispatch; rolling roll whose horizon==jump) the node is already doubly-anchored (hard start + reference end), so the row is redundant and SUPPRESSED — single solves receive zero new rows (byte-parity). Verified: 2day emits 0 rows, 1_week_rolling_wind emits 18 (3 pairs x 6 rolls; the horizon==jump final roll suppressed). t_first is per-d (not a global min), robust to multi-reveal single solves. The v_state-domain double inner-join (on (n,d,t) and (n,b,t)) drops state-less cells. Registered in autoscale _layer2_types.py (static literal). Test: test_rolling_na_ties_storage_across_branches keeps the net-charge witness (diagnostic localization) AND adds an absolute-v_state cross-branch-equality assertion (branches equal EACH OTHER at tied steps, not the nominal 0.5 — the shared level is the soft roll_continue value in continuation rolls). Both keep checked>0. Load-bearing: 0.06 fail without the family, ~0 with it. Deferred (NOT this slice): the HARD roll-to-roll carry-in (all branches inheriting the previous roll's actual jump end-state vs the current soft roll_continue seed) — spec W8b. Touches neither make_step_jump nor dtttdt. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- .../engine_polars/autoscale/_layer2_types.py | 1 + flextool/engine_polars/model.py | 117 ++++++++++++++++++ .../test_rolling_stochastic_na.py | 91 ++++++++++---- 3 files changed, 188 insertions(+), 21 deletions(-) diff --git a/flextool/engine_polars/autoscale/_layer2_types.py b/flextool/engine_polars/autoscale/_layer2_types.py index 66a9fa536..04e609ca1 100644 --- a/flextool/engine_polars/autoscale/_layer2_types.py +++ b/flextool/engine_polars/autoscale/_layer2_types.py @@ -477,6 +477,7 @@ class CstrFamily: # ── Non-anticipativity (cross-branch equality). RHS = 0; skip. # flextool/engine_polars/model.py:273, 306, 336, 364 "non_anticipativity_storage_use": CstrFamily(None), + "non_anticipativity_storage_state_first": CstrFamily(None), "non_anticipativity_online_integer": CstrFamily(None), "non_anticipativity_online_linear": CstrFamily(None), "non_anticipativity_reserve": CstrFamily(None), diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index b5d56c1ba..2efafac97 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -353,6 +353,123 @@ def _add_non_anticipativity_constraints( rhs_terms = {}, ) + # 1b. non_anticipativity_storage_state_first — delivers §D.6 + # branch-point continuity by pinning ABSOLUTE ``v_state`` at the + # FIRST tied step of each anchor period ``d``: + # + # v_state[n, d, t_first] − v_state[n, b, t_first] == 0 + # + # The storage net-charge family above ties only the per-(n, t) net + # charge (the consecutive ``v_state`` delta), so across a contiguous + # tied jump it fixes every branch's state SHAPE but leaves a free + # constant offset between the realized trunk and each forecast copy. + # In single-solve the trunk is hard start-bound and every branch is + # reference-value end-bound, so the offset telescopes to zero on its + # own; in rolling the untied post-reveal tail breaks that telescoping + # and the forecast copies float by a constant (~0.06 in + # ``1_week_rolling_wind``). Pinning absolute ``v_state`` at the first + # tied step restores the anchor: combined with the per-step net-charge + # ties it closes the whole contiguous jump absolutely. + # + # This tie is OPTIMUM-CONDITIONAL, not a hard structural anchor: it + # holds the branches equal to EACH OTHER at the branch point, but + # interior drift away from the shared level stays feasible whenever + # balance slack (``vq_state_up`` / ``vq_state_down``) absorbs it or + # the inflow differs across branches at the tied steps — the + # net-charge family controls neither. For the deterministic-inflow, + # zero-self-discharge reservoirs this fixture exercises, the drift is + # non-optimal and the tie binds. ``v_online`` / ``v_reserve`` need no + # analog: their own NA families already pin the ABSOLUTE variable at + # EVERY tied step, not just a net delta. + # + # NB: the hard roll-to-roll carry-in (all branches inheriting the + # previous roll's ACTUAL jump end-state, vs the current soft + # ``roll_continue`` seed) is a separate W8b concern. This slice ties + # the branches to each other at the branch point, closing the + # inter-branch offset; the shared level itself is still the soft + # carried value. + # + # PROACTIVE horizon-guard (byte-parity): emit the tie for an (n, d) + # ONLY when its tied timesteps are a STRICT SUBSET of that (n, d)'s + # ``v_state`` timeline, i.e. an untied post-reveal tail exists. When + # the tied set covers the full per-period horizon (single-solve + # dispatch) the node is already doubly anchored (hard start-binding + + # reference-value end), so the row is strictly redundant — suppress + # it so byte-parity-gated single solves receive ZERO new rows. + if (v_state is not None and d.groupStochastic is not None + and db_pairs.height > 0): + gn = d.group_node + gs = d.groupStochastic + if gn is not None and gn.height > 0 and gs is not None and gs.height > 0: + stoch_nodes = (gn.join(gs, on="g", how="inner") + .select("n").unique()) + if stoch_nodes.height > 0: + # ``v_state`` declared cells (n, d, t) — same source the Var + # was built from (``nodeState_dt``). + vstate_dt = v_state.frame.select("n", "d", "t").unique() + + # Anchor step PER-d (NOT a single global ``dtna.t.min()``): + # the first tied step of each anchor period, robust to a + # future multi-reveal single solve whose ``d`` have different + # tied ranges. + first_per_d = (dtna.select("d", "t").unique() + .group_by("d") + .agg(pl.col("t").min().alias("t"))) + + # Horizon-guard counts: per-d tied-step count vs per-(n, d) + # full ``v_state`` cell count; keep only STRICT subsets. + tied_counts = (dtna.select("d", "t").unique() + .group_by("d") + .agg(pl.len().alias("tied_count"))) + full_counts = (vstate_dt + .group_by("n", "d") + .agg(pl.len().alias("full_count"))) + nd_keep = (stoch_nodes + .join(dtna.select("d").unique(), how="cross") + .join(tied_counts, on="d", how="inner") + .join(full_counts, on=["n", "d"], how="inner") + .filter(pl.col("tied_count") < pl.col("full_count")) + .select("n", "d").unique()) + + if nd_keep.height > 0: + # Domain (n, d, b, t_first) = nd_keep ⋈ first-tied-step ⋈ + # db_pairs. + over = (nd_keep + .join(first_per_d, on="d", how="inner") + .join(db_pairs, on="d", how="inner") + .select("n", "d", "b", "t").unique()) + # Marker / state-less-cell guard: inner-join + # ``nodeState_dt`` on BOTH (n, d, t) and (n, b, t) so any + # cell lacking a declared ``v_state`` row is dropped. The + # (n, d, t) side is same-axis (native join); the (n, b, t) + # side is cross-Enum (over.b is branch-Enum, the renamed + # v_state domain's b is period-Enum), so cast both b keys + # to Utf8 for that join and restore axis types after. + over = over.join(vstate_dt, on=["n", "d", "t"], + how="inner") + vbt = (vstate_dt.rename({"d": "b"}) + .with_columns(pl.col("b").cast(pl.Utf8))) + over = (over + .with_columns(pl.col("b").cast(pl.Utf8)) + .join(vbt, on=["n", "b", "t"], how="inner") + .with_columns(cast_dim(pl.col("b"), None, "b")) + .select("n", "d", "b", "t").unique()) + if over.height > 0: + v_state_at_b = Var( + name=v_state.name + "__nab_first", + dims=("n", "b", "t"), + frame=v_state.frame.rename({"d": "b"}), + lower=v_state.lower, upper=v_state.upper, + ) + m.add_cstr( + "non_anticipativity_storage_state_first", + over = over, + sense = "==", + lhs_terms = {"v_d": v_state, + "v_b": -v_state_at_b}, + rhs_terms = {}, + ) + # 2. non_anticipativity_online_integer if v_online_integer is not None: # Domain: (p, d, b, t) over process_online_integer × db_pairs × dtna. diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py index 610d1104d..a57f074d3 100644 --- a/tests/engine_polars/test_rolling_stochastic_na.py +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -36,14 +36,22 @@ set outputs. So the two NA-coupling tests below assert for real (xfail decorators removed). -Deferred gap (NOT delivered by W3): absolute storage continuity — forecast -branches continuing from the committed roll-start state (spec §B / §D.6). The -storage NA constraint ties NET CHARGE (per-(n, t) charge delta, -``model.py:219-234``), not absolute ``v_state``. The realized trunk -``period1`` is start-anchored while the three forecast copies cyclic-self-link -their storage start, so absolute ``v_state`` carries a constant realized↔ -forecast offset (~0.06 here) even though the net-charge deltas are tied to -~1e-16. Closing that offset is a separate anchor-repointing slice. +§D.6 branch-point continuity (inter-branch) is now DELIVERED. The storage NA +stack ties BOTH the per-(n, t) NET CHARGE (per-step charge delta, +``model.py:219-234``) AND absolute ``v_state`` at the FIRST tied step of each +anchor period (``non_anticipativity_storage_state_first``, +``model.py`` 1b-block). Telescoping the per-step net-charge ties over the +contiguous jump off that absolute branch-point anchor closes the whole tied +range absolutely, so the constant realized↔forecast ``v_state`` offset +(previously ~0.06 here) collapses to ~0. The xfail that once guarded this is +gone. + +Remaining deferred item (NOT this slice): the HARD roll-to-roll carry-in — all +branches inheriting the previous roll's ACTUAL jump end-state rather than the +current soft ``roll_continue`` seed (spec W8b). Because that carry-in is still +soft, the SHARED branch-point level is the soft carried value (~0.38 in +continuation rolls), not the nominal 0.5 — so the test below asserts the +branches equal EACH OTHER at the branch point, never equality to 0.5. The structural signal the frames test asserts is the exact precondition the operational-NA gate (``model.py:4519-4522``) requires to emit the four @@ -203,23 +211,35 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): cohort includes the realized ``period1``, so this exercises realized↔ forecast coupling, not merely forecast↔forecast. - Absolute storage continuity is explicitly NOT delivered by W3 (spec §B / - §D.6): the realized trunk ``period1`` is start-anchored while the forecast - copies cyclic-self-link their storage start, so absolute ``v_state`` - carries a constant realized↔forecast offset (measured ~0.06 here) even - though the net-charge deltas collapse to ~1e-16. Closing that offset — - forecast branches continuing from the committed roll-start state — is a - separate anchor-repointing slice, not part of W3. - - Branches legitimately DIVERGE at non-tied steps, so the cross-branch - net-charge equality is restricted to the tied timesteps carried by that - roll's ``fd.dt_non_anticipativity``. The test also asserts that a - positive number of tied net-charge cells was actually checked so the - witness can never be silently vacuous. + Two witnesses run here: + + * **Net-charge** (diagnostic localization): the per-step ``v_state`` delta + is equal across the cohort ``d`` at each tied ``(n, t)``. This stays in + place so a future regression reads as the fine "flows tied but states + offset" rather than the coarse "states not tied". + * **Absolute ``v_state``** (§B / §D.6, now DELIVERED): at each tied + ``(n, t)`` the spread of ABSOLUTE ``v_state`` across the branch cohort is + ≤ 1e-6 — the constant realized↔forecast offset (previously ~0.06) is + gone, closed by ``non_anticipativity_storage_state_first`` pinning the + absolute state at the first tied step and the net-charge ties + telescoping the rest of the contiguous jump. + + The absolute assertion compares the branches to EACH OTHER (cross-branch + spread), NOT to the nominal 0.5 / ``roll_continue`` value: the hard + roll-to-roll carry-in (spec W8b) is not yet delivered, so in continuation + rolls the shared branch-point level is the SOFT carried value (~0.38), and + asserting against 0.5 would wrongly fail. + + Branches legitimately DIVERGE at non-tied steps, so both equalities are + restricted to the tied timesteps carried by that roll's + ``fd.dt_non_anticipativity``. The test also asserts that a positive number + of tied cells was actually checked for each witness so neither can be + silently vacuous. """ steps = _steps(rolling_result) tol = 1e-6 checked = 0 + checked_abs = 0 for name, s in steps.items(): sol = s.solution assert sol is not None, f"roll {name!r} retained no solution" @@ -288,7 +308,36 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): ) checked += 1 + # ── Absolute v_state witness (§B / §D.6) ── + # Spread of ABSOLUTE v_state across the branch cohort at each tied + # (n, t). Delivered by non_anticipativity_storage_state_first + + # net-charge telescoping; must collapse to ~0 (offset gone). Compare + # branches to EACH OTHER, never to 0.5 (the shared level is the soft + # carried value under the still-deferred hard carry-in, W8b). + abs_spread = (vs_tied + .group_by(["__n", "__t"]) + .agg( + n_branches=pl.col("value").len(), + vmax=pl.col("value").max(), + vmin=pl.col("value").min(), + ) + .filter(pl.col("n_branches") > 1)) + for row in abs_spread.iter_rows(named=True): + assert row["vmax"] - row["vmin"] <= tol + 1e-6 * max( + 1.0, abs(row["vmax"]), abs(row["vmin"]) + ), ( + f"roll {name!r}: absolute v_state spread " + f"{row['vmax'] - row['vmin']} at (n={row['__n']}, " + f"t={row['__t']}) exceeds tol across branches — §D.6 " + f"branch-point continuity broken" + ) + checked_abs += 1 + assert checked > 0, ( "no tied (n, t) net-charge cells with >1 branch were checked — the " "storage NA witness would be vacuous" ) + assert checked_abs > 0, ( + "no tied (n, t) absolute-v_state cells with >1 branch were checked — " + "the §D.6 branch-point continuity witness would be vacuous" + ) From d00bb6a4d1d7ec06481951f54a9fbca558518e00 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 6 Oct 2026 12:43:12 +0300 Subject: [PATCH 68/87] feat(rolling): warn on investment intent in a flat rolling_window solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investment is period-granular (v_invest is indexed (p,d)/(n,d), no timestep axis) and a rolling solve advances past period beginnings, so a flat rolling_window solve builds no v_invest at all — authored invest params are silently ignored (root: _resolve_synthetic_solve reads the `_roll_N` suffix as an invest-period anchor). The intended design is to run a separate INVESTMENT solve BEFORE the rolling (operational) solves; rolling is for operations. This is not a bug to fix and not a case to validate — but it should not be silent. Adds a once-per-parent logger.warning in RecursiveSolveBuilder._process_rolling_solve when a rolling_window solve ITSELF carries investment intent (invest_periods / realized_invest_periods non-empty, or stochastic_invest_method != none) — keyed on solve-authored params, NOT on invest-eligible entities existing, so the correct invest-solve-then-rolling pattern never warns. Routed through a module-level logger because the orchestrator deliberately raises the run logger to ERROR during the cascade (_drive_cascade), which would swallow self.logger; the module logger is the established engine-advisory convention and reaches stdout via the CLI basicConfig. Log-only: no LP/frame/output change; an idempotency guard keys the one-shot on the parent solve name. Adds tests/engine_polars/test_rolling_stochastic_invest_noop.py pinning the intended + safe behavior of rolling + stochastic + investment: all 7 rolls optimal; the W3-activated period_branch_full consumers (is_genuinely_stochastic, synthetic_branch_tokens, the handoff guards) run inert and never raise; the known no-build (pd_invest_set is None per roll) is asserted as a tripwire so a future change that starts building v_invest in rolling trips loudly; and the new warning fires exactly once naming the solve. Fixture built by in-test injection on a tmp DB (invariant #3); committed stochastics.json untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_recursive_solve.py | 74 +++++ .../test_rolling_stochastic_invest_noop.py | 274 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 tests/engine_polars/test_rolling_stochastic_invest_noop.py diff --git a/flextool/engine_polars/_recursive_solve.py b/flextool/engine_polars/_recursive_solve.py index 5bd34f9c5..a0c2b2be2 100644 --- a/flextool/engine_polars/_recursive_solve.py +++ b/flextool/engine_polars/_recursive_solve.py @@ -72,6 +72,12 @@ from flextool.engine_polars._solve_state import RunnerState +# Module-level logger for advisories that must survive the cascade's +# deliberate ERROR-silencing of the per-run ``state.logger`` (see +# :meth:`RecursiveSolveBuilder._warn_if_rolling_carries_invest`). +_MODULE_LOGGER = logging.getLogger(__name__) + + # --------------------------------------------------------------------------- # ParentSolveInfo — walked through the tree # --------------------------------------------------------------------------- @@ -104,6 +110,11 @@ class RecursiveSolveBuilder: def __init__(self, state: "RunnerState") -> None: self.state = state self.logger: logging.Logger = state.logger + # Solve names already warned about carrying investment intent into a + # flat rolling_window solve (see :meth:`_warn_if_rolling_carries_invest`). + # Keyed by base solve name so the warning fires once per PARENT rolling + # solve, never once per roll. + self._rolling_invest_warned: set[str] = set() # ------------------------------------------------------------------ # Rolling expansion @@ -322,6 +333,65 @@ def _filter_time_list_by_parent_scope( filtered[period] = timesteps return filtered + # ------------------------------------------------------------------ + # Investment-intent-in-rolling warning + # ------------------------------------------------------------------ + + def _warn_if_rolling_carries_invest(self, solve: str) -> None: + """Warn once when a flat rolling solve is authored with invest intent. + + Investment in FlexTool is period-granular (``v_invest`` is indexed by + ``(p, d)`` / ``(n, d)`` with no timestep axis) and a rolling-window + solve advances PAST the beginning of each period as it jumps, so the + synthetic per-roll sub-solves (``_roll_``) never anchor an + invest period — every invest-set resolves to ``None`` and NO + ``v_invest`` variable is built. This is silent: the rolling solve runs + to optimum but simply omits investment. + + The intended pattern is to run a SEPARATE investment solve BEFORE the + rolling (operational) solve and hand its realized capacity forward; + rolling is for operational modelling only. + + This fires at most once per PARENT rolling solve (keyed on *solve*), + never per roll, and ONLY when the solve ITSELF authors investment + intent — ``invest_periods`` non-empty, ``realized_invest_periods`` + non-empty, or ``stochastic_invest_method != "none"``. A rolling solve + that merely sits downstream of an invest solve (invest-eligible + entities exist in the model, but the rolling solve authors none of + these knobs) does NOT trigger it. WARNING only — it changes no LP, + frame, or output. + """ + if solve in self._rolling_invest_warned: + return + has_invest_periods = bool(self.state.solve.invest_periods.get(solve)) + has_realized_invest = bool( + self.state.solve.realized_invest_periods.get(solve) + ) + stochastic_invest = ( + self.state.solve.stochastic_invest_method_for(solve) != "none" + ) + if not (has_invest_periods or has_realized_invest or stochastic_invest): + return + self._rolling_invest_warned.add(solve) + # Emit via a MODULE-level logger, NOT ``self.logger``: the orchestrator + # deliberately raises ``state.logger`` to ERROR during the cascade + # (``_orchestration._drive_cascade``) to silence per-solve chatter, so + # a WARNING through ``self.logger`` would be filtered and never seen. + # This advisory must survive that silencing, so it routes through + # ``_MODULE_LOGGER`` (effective level inherited from root). + _MODULE_LOGGER.warning( + "Solve '%s' is a rolling_window solve authored with investment " + "intent (invest_periods / realized_invest_periods / " + "stochastic_invest_method), but investment is NOT built in a flat " + "rolling_window solve: investment is period-granular (v_invest has " + "no timestep axis) and a rolling solve advances past period " + "beginnings, so no v_invest variable is created and the " + "investment intent is silently ignored. Run a separate investment " + "solve BEFORE this rolling solve and hand its realized capacity " + "forward; rolling solves are for operational modelling only.", + solve, + ) + # ------------------------------------------------------------------ # Rolling-solve dispatcher # ------------------------------------------------------------------ @@ -341,6 +411,10 @@ def _process_rolling_solve( roll's first timestep + the parent's jump as the child's ``start`` / ``duration``. """ + # Log-only guard: a flat rolling solve silently drops any investment + # intent authored on the solve itself (fires once per parent solve). + self._warn_if_rolling_carries_invest(solve) + # Lazy import: keep the cross-module dependency one-way at # import time; both modules are needed only when a parent's roll # constrains a child. diff --git a/tests/engine_polars/test_rolling_stochastic_invest_noop.py b/tests/engine_polars/test_rolling_stochastic_invest_noop.py new file mode 100644 index 000000000..e0b4385d3 --- /dev/null +++ b/tests/engine_polars/test_rolling_stochastic_invest_noop.py @@ -0,0 +1,274 @@ +"""Regression lock: rolling + stochastic + investment is a SAFE no-build. + +This is the "#1" guard for the known, INTENDED behaviour that a flat +``rolling_window`` solve authored with investment intent does NOT build any +``v_invest`` variable — and does so safely (every roll solves, the W3 +non-anticipativity guards stay inert, nothing raises). + +Why this is intended, not a bug +------------------------------- +Investment in FlexTool is period-granular: ``v_invest`` is indexed ``(p, d)`` +/ ``(n, d)`` with no timestep axis. A ``rolling_window`` solve expands into +synthetic per-roll sub-solves named ``_roll_`` whose roll starts +advance PAST the beginning of each period. ``_resolve_synthetic_solve`` +(``flextool/engine_polars/_derived_params.py``) reads a ``roll_N`` token as an +invest-period anchor, so ``apply_synthetic_invest_sets`` leaves every +invest-set ``None`` — no ``v_invest`` is created and the authored investment +intent is silently dropped. + +The DESIGN is to run a separate INVESTMENT solve BEFORE the rolling +(operational) solve and hand its realized capacity forward; rolling is for +operational modelling only. This test therefore pins: + +* the solve is SAFE — all rolls reach a strict optimum and the run raises no + ``FlexToolConfigError``; +* the W3 per-roll activation of the ``period_branch_full`` consumers is + CORRECT and inert here — ``is_genuinely_stochastic`` is ``True`` per roll and + ``synthetic_branch_tokens`` is non-empty, yet nothing misfires; +* the KNOWN silent no-build — ``pd_invest_set is None`` on every roll. A + future change that starts building ``v_invest`` in a flat rolling solve will + trip assertion (3) and force a conscious update of this test (and of the + Task-1 warning below); +* the Task-1 warning — ``_recursive_solve`` emits exactly one WARNING per + parent rolling solve explaining the no-build and pointing at a pre-rolling + invest solve. + +The fixture is built by IN-TEST injection onto a tmp copy of +``tests/fixtures/stochastics.json`` (invariant #3: DB built from JSON/schema, +never a checked-in sqlite) so the committed fixture — and the existing +``1_week_rolling_wind`` tests that read it — stay untouched. +""" +from __future__ import annotations + +import base64 +import json +import logging +import os +from pathlib import Path + +import pytest + +from flextool.engine_polars._orchestration import run_chain_from_db +from flextool.engine_polars._solve_state import FlexToolConfigError +from flextool.engine_polars._stochastic_detect import ( + is_genuinely_stochastic, + synthetic_branch_tokens, +) + +pytestmark = [pytest.mark.solver] + +SCENARIO = "1_week_rolling_wind" +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" + +# 168-step horizon, rolling_solve_jump = 24 → 7 rolls (see +# tests/engine_polars/test_rolling_stochastic_na.py docstring). +EXPECTED_ROLLS = 7 + + +def _pack(value, value_type): + """Spine parameter-value encoding: ``[base64(json), value_type]``.""" + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _build_invest_spec() -> dict: + """Extend the rolling-stochastic fixture with investment intent. + + Mirrors the exploration setup: gas_plant becomes invest-eligible and the + ``1week_rolling`` solve authors ``stochastic_invest_method=recourse`` + + ``invest_periods`` + ``realized_invest_periods`` over ``period1``. The + invest params land on a NEW ``invest`` alternative wired into the end of + the ``1_week_rolling_wind`` scenario chain, so the original fixture + alternatives are not mutated. + """ + src = json.loads((FIXTURES_DIR / "stochastics.json").read_text()) + + src["alternatives"].append(["invest", "recourse invest on gas_plant"]) + + # Rewire the scenario chain: ... -> 1week_rolling -> invest -> None. + new_sa = [] + for sa in src["scenario_alternatives"]: + if sa[0] == SCENARIO and sa[1] == "1week_rolling": + new_sa.append([SCENARIO, "1week_rolling", "invest"]) + else: + new_sa.append(sa) + new_sa.append([SCENARIO, "invest", None]) + src["scenario_alternatives"] = new_sa + + src["parameter_values"].extend([ + # gas_plant becomes invest-eligible. + ["unit", "gas_plant", "invest_method", _pack("invest_total", "str"), + "invest"], + ["unit", "gas_plant", "invest_cost", _pack(50.0, "float"), "invest"], + ["unit", "gas_plant", "discount_rate", _pack(0.05, "float"), "invest"], + ["unit", "gas_plant", "lifetime", _pack(1.0, "float"), "invest"], + ["unit", "gas_plant", "invest_max_total", _pack(5000.0, "float"), + "invest"], + ["unit", "gas_plant", "virtual_unitsize", _pack(100.0, "float"), + "invest"], + # Solve-level investment intent (recourse over the single period). + ["solve", "1week_rolling", "stochastic_invest_method", + _pack("recourse", "str"), "invest"], + ["solve", "1week_rolling", "invest_periods", + _pack({"value_type": "str", "data": ["period1"]}, "array"), + "invest"], + ["solve", "1week_rolling", "realized_invest_periods", + _pack({"value_type": "str", "data": ["period1"]}, "array"), + "invest"], + ]) + return src + + +@pytest.fixture(scope="module") +def rolling_invest_db_url(tmp_path_factory) -> str: + """Build the rolling+stochastic+invest DB from the injected JSON spec.""" + import sys + + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from db_utils import json_to_db + from flextool.update_flextool.db_migration import migrate_database + + spec = _build_invest_spec() + work = tmp_path_factory.mktemp("rolling_stoch_invest") + jpath = work / "rolling_stoch_invest.json" + jpath.write_text(json.dumps(spec, indent=2) + "\n") + dbpath = work / "rolling_stoch_invest.sqlite" + url = json_to_db(jpath, dbpath) + migrate_database(url) + return url + + +class _WarningCollector(logging.Handler): + """Collect formatted WARNING+ messages across the module-scoped run. + + ``caplog`` is function-scoped and cannot back a module-scoped fixture, so + the Task-1 warning (emitted during the single memoised run) is captured + with a dedicated handler attached to the ``flextool`` logger. + """ + + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self.messages: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.messages.append(record.getMessage()) + + +@pytest.fixture(scope="module") +def rolling_invest_result( + rolling_invest_db_url, test_solver_config_dir, tmp_path_factory +): + """Run the rolling+stochastic+invest scenario ONCE for the module. + + Runs under ``FLEXTOOL_AUTOSCALE_STRICT=1`` (invariant #1) so an autoscale + gap is loud rather than silently reverting to an un-scaled LP. Captures + WARNING-level records across the whole run so the Task-1 warning can be + asserted. + """ + work_folder = tmp_path_factory.mktemp("rolling_stoch_invest_run") + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + collector = _WarningCollector() + flextool_logger = logging.getLogger("flextool") + prev_level = flextool_logger.level + if prev_level > logging.WARNING or prev_level == logging.NOTSET: + flextool_logger.setLevel(logging.WARNING) + flextool_logger.addHandler(collector) + error = None + steps = None + try: + steps = run_chain_from_db( + input_db_url=rolling_invest_db_url, + scenario_name=SCENARIO, + work_folder=work_folder, + solver_config_dir=test_solver_config_dir, + warm=False, + keep_solutions=True, + ) + except FlexToolConfigError as exc: + error = exc + finally: + flextool_logger.removeHandler(collector) + flextool_logger.setLevel(prev_level) + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + return {"steps": steps, "error": error, "warnings": collector.messages} + + +def _steps(result): + if result["error"] is not None: + raise result["error"] + return result["steps"] + + +def test_rolling_invest_all_rolls_optimal(rolling_invest_result): + """(1) The solve is SAFE: every roll reaches a strict optimum, no raise.""" + steps = _steps(rolling_invest_result) + assert len(steps) == EXPECTED_ROLLS, ( + f"expected {EXPECTED_ROLLS} rolls, got {len(steps)}" + ) + assert all(s.optimal for s in steps.values()), { + name: s.optimal for name, s in steps.items() + } + + +def test_rolling_invest_w3_guards_inert(rolling_invest_result): + """(2) W3 per-roll stochastic guards are CORRECT and inert. + + Each roll's ``flex_data`` is genuinely stochastic and carries non-empty + synthetic branch tokens, yet the whole run raised no ``FlexToolConfigError`` + — the W3 activation neither misfires nor crashes under investment intent. + """ + # No FlexToolConfigError was raised by the run. + assert rolling_invest_result["error"] is None, rolling_invest_result["error"] + steps = _steps(rolling_invest_result) + for name, s in steps.items(): + fd = s.flex_data + assert fd is not None, f"roll {name!r} retained no flex_data" + assert is_genuinely_stochastic(fd) is True, ( + f"roll {name!r}: is_genuinely_stochastic should be True" + ) + tokens = synthetic_branch_tokens(fd.period_branch_full) + assert tokens, ( + f"roll {name!r}: synthetic_branch_tokens must be non-empty" + ) + + +def test_rolling_invest_no_v_invest_built(rolling_invest_result): + """(3) KNOWN silent no-build: ``pd_invest_set is None`` on every roll. + + Documents that a flat rolling solve builds NO investment. If a future + change starts building ``v_invest`` here this assertion trips, forcing a + conscious review of this test and the Task-1 warning. + """ + steps = _steps(rolling_invest_result) + for name, s in steps.items(): + fd = s.flex_data + assert fd is not None, f"roll {name!r} retained no flex_data" + assert fd.pd_invest_set is None, ( + f"roll {name!r}: pd_invest_set is unexpectedly built " + f"({fd.pd_invest_set!r}) — a flat rolling solve is not supposed " + f"to build v_invest; review this test and the Task-1 warning" + ) + + +def test_rolling_invest_warns_once(rolling_invest_result): + """(4) The Task-1 warning fires exactly once, naming the solve. + + Emitted once per PARENT rolling solve (not per roll) — explains the + no-build and points at a pre-rolling investment solve. + """ + _steps(rolling_invest_result) # surface any memoised error first + hits = [ + w for w in rolling_invest_result["warnings"] + if "1week_rolling" in w + and "investment is NOT built in a flat rolling_window solve" in w + ] + assert len(hits) == 1, ( + f"expected exactly one investment-in-rolling warning naming " + f"'1week_rolling', got {len(hits)}: {hits}" + ) From e8418282557381de1cdab22276f9650e2c21ad62 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 6 Oct 2026 22:44:19 +0300 Subject: [PATCH 69/87] test(stochastic): realized-dispatch handoff e2e (W8.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the W8 premise that unblocks W4: in a stochastic model, realized OPERATIONS can come from a separate deterministic realized-dispatch solve whose investments are FIXED (via the existing invest→dispatch handoff) from the stochastic solve. Proven by a new fixture + e2e with NO engine change — the machinery (handoff capture + synthetic-invest drop + realized last-wins + output union) already exists; W8.1 wires a stochastic→deterministic pair and locks the behaviour in. Fixture `w8_realized_dispatch` (new, JSON-built): model.solves = [stoch_rd, stoch_rd_realized_dispatch]. stoch_rd is single_solve fan-at-first, stochastic_invest_method=none (shared first-stage invest), branches rlz 0.25 / high 0.375 / low 0.375 (sum 1.0), genuinely stochastic; it commits v_invest[peaker]=60. stoch_rd_realized_dispatch is deterministic, listed AFTER (required for last-wins), consumes the 60 MW as fixed existing capacity (mirrors the proven lh2_invest→lh2_dispatch precedent). e2e asserts: (a) both solves optimal; (b) dispatch receives the stochastic solve's v_invest as fixed existing capacity (dispatch builds NO v_invest, p_entity_invested[peaker]=60, peaker existing=0 in base so the 60 arrives only via the handoff — not pre-baked); (c) last-wins empties the stochastic solve's realized window (stoch realized_dispatch None; dispatch owns p2035/t0001-t0003); (d) hand-calc dispatch objective 5,256,000 (60 MW · 8760 h · 10, annualized) at rel=1e-9. Runs under FLEXTOOL_AUTOSCALE_STRICT=1. Documented W4 boundary (not patched): the deterministic dispatch reads the realized series only while that series is DETERMINISTIC; once forecasts diverge from realized (W4), feeding the realized data to the dispatch solve needs a real mechanism. W8.1 keeps realized operational data deterministic (readable by both) with genuine forecast branches, fully exercising the handoff + last-wins + output routing. Author-declared vs engine-automatic realized-dispatch is a deferred W4-era decision. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- .../test_w8_realized_dispatch.py | 346 + .../fixtures/build_stoch_realized_dispatch.py | 271 + tests/fixtures/stoch_realized_dispatch.json | 6263 +++++++++++++++++ 3 files changed, 6880 insertions(+) create mode 100644 tests/engine_polars/test_w8_realized_dispatch.py create mode 100644 tests/fixtures/build_stoch_realized_dispatch.py create mode 100644 tests/fixtures/stoch_realized_dispatch.json diff --git a/tests/engine_polars/test_w8_realized_dispatch.py b/tests/engine_polars/test_w8_realized_dispatch.py new file mode 100644 index 000000000..2154562c9 --- /dev/null +++ b/tests/engine_polars/test_w8_realized_dispatch.py @@ -0,0 +1,346 @@ +"""W8.1 — realized operations from a SEPARATE deterministic dispatch solve. + +This end-to-end test pins the W8 premise: in a genuinely stochastic +model, the reported realized OPERATIONS can come from a separate +*deterministic* realized-dispatch solve whose investments are FIXED — +via the existing invest→dispatch handoff — from the stochastic solve. +Establishing that realized ops have a home is what later makes W4 ("hedge +forecasts only") safe. Whether the realized-dispatch pairing is +author-declared (as in this fixture) or engine-automatic is a deferred +W4-era decision. + +No engine code changes: the fixture wires a stochastic→deterministic +two-solve chain and this test locks the behaviour in. The machinery +exercised is identical to the proven deterministic ``lh2_invest → +lh2_dispatch`` precedent (``test_invest_dispatch_handoff.py``), only with +a genuinely stochastic first solve. + +Fixture (``tests/fixtures/stoch_realized_dispatch.json``, built by +``tests/fixtures/build_stoch_realized_dispatch.py``): + + * ``stoch_rd`` — GENUINELY stochastic, ``solve_mode=single_solve`` + (fan-at-first), ``stochastic_invest_method`` at the DEFAULT ``none`` + (shared first-stage invest). Three branches rlz/high/low with + weights SUMMING TO 1.0 (0.25 / 0.375 / 0.375). Invests a peaker to + cover the per-step residual (60 MW). + * ``stoch_rd_realized_dispatch`` — DETERMINISTIC (no + ``stochastic_branches``), listed AFTER in ``model.solves``, covering + the SAME realized period p2035, consuming the stochastic solve's + committed 60 MW peaker as FIXED existing capacity through the + handoff. It builds no ``v_invest`` of its own. + +Four assertions (see the per-test docstrings): + (a) both solves reach a strict optimum; + (b) the dispatch receives the committed capacity as FIXED existing + capacity — it builds NO invest set / ``v_invest`` for the peaker, + and its effective invested capacity equals the stochastic solve's + ``v_invest``; + (c) the reported realized operations are SOURCED FROM the dispatch + solve — the stochastic solve's realized (period, step) window is + emptied by last-wins, so the dispatch solve owns the realized + output; + (d) a hand-calc of the dispatch solve's objective. + +Note on the W4 boundary (why the realized data is deterministic): both +solves share one scenario, so a parameter resolves to the same value for +both. A branch-DISTINCT (3-deep branch×time_start×time Map) profile is +only foldable by a genuinely stochastic solve; the deterministic +dispatch solve drops it and wind collapses to a 0 upper_limit (verified +while authoring this fixture). Letting the hedge forecasts diverge from +the realized data the dispatch reads is exactly the deferred W4 change, +so W8.1 keeps the realized operational data deterministic (readable by +both solves) and the forecast branches structurally genuine. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import polars as pl +import pytest + +FLEXTOOL_ROOT = Path(__file__).resolve().parents[2] +FIXTURE_JSON = ( + FLEXTOOL_ROOT / "tests" / "fixtures" / "stoch_realized_dispatch.json" +) + +SCENARIO = "w8_realized_dispatch" +STOCH_SOLVE = "stoch_rd" +DISPATCH_SOLVE = "stoch_rd_realized_dispatch" +REALIZED_STEPS = {"t0001", "t0002", "t0003"} + +# Hand-calc constants (see module docstring + build script). +COMMITTED_PEAKER_MW = 60.0 # shared first-stage v_invest +# dispatch objective = peaker 60 MW · 10 CUR/MWh · 8760 h/yr. +HOURS_PER_YEAR = 8760.0 +PEAKER_VAR_COST = 10.0 +DISPATCH_OBJECTIVE = COMMITTED_PEAKER_MW * PEAKER_VAR_COST * HOURS_PER_YEAR + + +def _build_db(tmp_path: Path) -> str: + """Materialise the committed JSON fixture into a fresh SQLite and + return its url (build-from-JSON + migrate, per CLAUDE.md invariant 3; + never read a checked-in .sqlite).""" + if not FIXTURE_JSON.exists(): + pytest.skip(f"fixture JSON not present: {FIXTURE_JSON}") + tests_dir = FLEXTOOL_ROOT / "tests" + if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) + from db_utils import json_to_db # noqa: E402 + from flextool.update_flextool.db_migration import migrate_database + + db_path = tmp_path / "stoch_realized_dispatch.sqlite" + url = json_to_db(FIXTURE_JSON, db_path) + migrate_database(url) + return url + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + """Run the two-solve chain once under ``FLEXTOOL_AUTOSCALE_STRICT=1`` + with ``warm=False`` + ``keep_solutions=True`` (so per-step + ``flex_data`` / ``solution`` survive) and return + ``(steps, work_folder)``.""" + from flextool.engine_polars import run_chain_from_db + + tmp_path = tmp_path_factory.mktemp("w8_realized_dispatch") + url = _build_db(tmp_path) + work_folder = tmp_path / "work" + work_folder.mkdir() + + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + steps = run_chain_from_db( + url, SCENARIO, work_folder=work_folder, + warm=False, keep_solutions=True, + ) + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + assert steps, "run_chain_from_db produced no orchestration steps" + return steps, work_folder + + +def _step(steps, needle): + key = next((k for k in steps if needle in k), None) + assert key is not None, f"no {needle!r} step in {list(steps)}" + return steps[key] + + +def _as_frame(param): + """Unwrap a FlexData Param (``.frame``) or passthrough a polars frame.""" + if param is None: + return None + return param.frame if hasattr(param, "frame") else param + + +# --------------------------------------------------------------------------- +# (a) both solves reach a strict optimum +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_both_solves_optimal(chain) -> None: + """(a) The stochastic invest solve and the deterministic + realized-dispatch solve both complete with a strict optimum.""" + steps, _wf = chain + stoch = _step(steps, STOCH_SOLVE) + dispatch = _step(steps, DISPATCH_SOLVE) + + assert stoch.solution is not None, "stochastic solve produced no solution" + assert stoch.optimal, "stochastic solve not optimal" + assert dispatch.solution is not None, "dispatch solve produced no solution" + assert dispatch.optimal, "dispatch solve not optimal" + + +# --------------------------------------------------------------------------- +# (b) the dispatch receives the committed capacity as FIXED existing capacity +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_dispatch_capacity_is_fixed_from_stochastic_invest(chain) -> None: + """(b) The dispatch solve builds NO invest for the peaker — its + capacity is FIXED at the stochastic solve's committed ``v_invest``. + + Detection of "capacity fixed": + * the stochastic solve DID build a process invest set + deposited a + non-empty handoff with ``realized_invest[peaker] = 60``; + * the dispatch solve's FlexData has ``pd_invest_set is None`` (it + builds NO ``v_invest`` for the peaker) yet carries + ``p_entity_invested[peaker] = 60`` — i.e. the 60 MW arrived purely + as fixed existing capacity through the handoff overlay, not as a + decision variable it could re-optimise; + * the two values are equal (the dispatch's fixed capacity == + the stochastic solve's investment decision). + """ + steps, _wf = chain + stoch = _step(steps, STOCH_SOLVE) + dispatch = _step(steps, DISPATCH_SOLVE) + + # The stochastic solve genuinely invested (precondition for a handoff). + assert stoch.flex_data is not None + assert stoch.flex_data.pd_invest_set is not None, ( + "stochastic solve built no process invest set" + ) + pcol = stoch.flex_data.pd_invest_set.columns[0] + assert "peaker" in set( + stoch.flex_data.pd_invest_set[pcol].to_list() + ), "peaker is not in the stochastic solve's invest set" + + assert stoch.handoff is not None and not stoch.handoff.is_empty(), ( + "stochastic solve deposited no handoff" + ) + ri = stoch.handoff.realized_invest + assert ri is not None and ri.height > 0 + ent_col, val_col = ri.columns[0], ri.columns[-1] + committed = { + r[ent_col]: float(r[val_col]) for r in ri.iter_rows(named=True) + } + assert committed.get("peaker") == pytest.approx(COMMITTED_PEAKER_MW), ( + f"stochastic solve committed {committed.get('peaker')} MW peaker, " + f"expected {COMMITTED_PEAKER_MW}" + ) + + # The dispatch solve builds NO invest for the peaker ... + assert dispatch.flex_data is not None + assert dispatch.flex_data.pd_invest_set is None, ( + "dispatch solve built a process invest set — it should consume the " + "stochastic solve's investment as fixed existing capacity, not " + "re-optimise it" + ) + disp_sol = dispatch.solution + disp_vars = set(getattr(disp_sol, "_vars", {}).keys()) + assert not any("v_invest" in v for v in disp_vars), ( + f"dispatch solve built v_invest variables {sorted(disp_vars)}" + ) + + # ... yet its effective invested capacity equals the committed 60 MW. + pei = _as_frame(dispatch.flex_data.p_entity_invested) + assert pei is not None, ( + "dispatch FlexData has no p_entity_invested — the handoff overlay " + "did not reach the dispatch solve" + ) + pei_ent, pei_val = pei.columns[0], pei.columns[-1] + dispatch_cap = { + r[pei_ent]: float(r[pei_val]) for r in pei.iter_rows(named=True) + } + assert dispatch_cap.get("peaker") == pytest.approx(COMMITTED_PEAKER_MW), ( + f"dispatch peaker fixed capacity {dispatch_cap.get('peaker')} != " + f"stochastic v_invest {COMMITTED_PEAKER_MW}" + ) + assert dispatch_cap["peaker"] == pytest.approx(committed["peaker"]), ( + "dispatch fixed capacity must equal the stochastic solve's v_invest" + ) + + +# --------------------------------------------------------------------------- +# (c) reported realized operations are sourced from the dispatch solve +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_realized_operations_sourced_from_dispatch_solve(chain) -> None: + """(c) The dispatch solve OWNS the reported realized operations. + + Both solves declare ``realized_periods=[p2035]`` over the same steps. + The dispatch solve is listed AFTER the stochastic solve in + ``model.solves``, so the engine's last-wins resolution + (``_native_run_model`` reversed-order claim) TRUNCATES the stochastic + solve's realized window and the dispatch solve owns it. + + Detection via each solve's per-solve realized window + (``flex_data.realized_dispatch`` — the (period, step) pairs that solve + persists as realized, which drive the multi-solve output union): + * the stochastic solve — though it DECLARES p2035 realized — has an + EMPTY realized window (None / 0 rows): emptied by last-wins; + * the dispatch solve owns the full p2035 / {t0001,t0002,t0003} + realized window. + """ + steps, _wf = chain + stoch = _step(steps, STOCH_SOLVE) + dispatch = _step(steps, DISPATCH_SOLVE) + + stoch_rd = stoch.flex_data.realized_dispatch + # Emptied by last-wins: the stoch solve DECLARES realized p2035 (run + # alone it owns a (3,2) window), but the later-declared dispatch solve + # claims those cells, so here it is None (or a frame with no p2035 rows). + # The dispatch-side assertions below pin the complementary direction, so + # a last-wins regression fails loudly either way (stoch keeps the window + # → this assert fires; dispatch loses it → the disp_rd asserts fire). + if stoch_rd is not None: + remaining = stoch_rd.filter(pl.col("period") == "p2035") + assert remaining.height == 0, ( + "stochastic solve still owns realized p2035 cells — last-wins " + f"did not hand them to the dispatch solve:\n{stoch_rd}" + ) + + disp_rd = dispatch.flex_data.realized_dispatch + assert disp_rd is not None and disp_rd.height > 0, ( + "dispatch solve has no realized window — it should OWN realized " + "p2035 after last-wins" + ) + owned = disp_rd.filter(pl.col("period") == "p2035") + assert set(owned["step"].to_list()) == REALIZED_STEPS, ( + f"dispatch realized window {sorted(owned['step'].to_list())} != " + f"{sorted(REALIZED_STEPS)}" + ) + + +# --------------------------------------------------------------------------- +# (d) hand-calc of the dispatch solve's objective +# --------------------------------------------------------------------------- + + +@pytest.mark.solver +def test_dispatch_objective_hand_calc(chain) -> None: + """(d) Hand-calc the deterministic dispatch solve's objective. + + Realized (deterministic) data read by the dispatch solve: + * demand = 100 MW/step, 3 steps of 1 h; + * wind upper_limit profile 0.40 → wind cap 0.40 · 100 = 40 MW/step + (cost 0), so wind supplies 40 MW/step; + * peaker fills the residual = 100 − 40 = 60 MW/step at 10 CUR/MWh; + * the peaker's fixed existing capacity (60 MW) is non-binding and + carries NO annuity in the dispatch solve. + + The 3-step timeset represents a full year (each modelled hour stands + for 8760/3 h), so the annual peaker generation is 60 MW · 8760 h and + the dispatch objective is pure fuel: + + objective = 60 MW · 8760 h · 10 CUR/MWh = 5 256 000 CUR. + + The physical split (peaker 60 MW/step, wind 40 MW/step) is also + asserted directly off the dispatch solution — it is scaling-free. + """ + steps, wf = chain + dispatch = _step(steps, DISPATCH_SOLVE) + + # Objective from the per-solve output (independent of solver internals). + obj = float( + pl.read_parquet(wf / "output_raw" / f"v_obj__{DISPATCH_SOLVE}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(DISPATCH_OBJECTIVE, rel=1e-9), ( + f"dispatch objective {obj} != hand-calc {DISPATCH_OBJECTIVE}" + ) + + # Physical dispatch split (scaling-free): peaker 60 MW, wind 40 MW/step. + flows = dispatch.solution.value("v_flow") + peaker = flows.filter(pl.col("source") == "peaker") + assert peaker.height == 3 + assert all( + v == pytest.approx(60.0) for v in peaker["value"].to_list() + ), f"peaker dispatch != 60 MW/step:\n{peaker}" + # wind's v_flow is reported as a capacity fraction (0.40 of existing + # 100 MW = 40 MW); the balance 40 + 60 = 100 MW = demand confirms it. + wind = flows.filter(pl.col("source") == "wind") + assert wind.height == 3 + assert all( + v == pytest.approx(0.40) for v in wind["value"].to_list() + ), f"wind dispatch != 0.40 capacity-fraction (40 MW):\n{wind}" diff --git a/tests/fixtures/build_stoch_realized_dispatch.py b/tests/fixtures/build_stoch_realized_dispatch.py new file mode 100644 index 000000000..7a53f37d6 --- /dev/null +++ b/tests/fixtures/build_stoch_realized_dispatch.py @@ -0,0 +1,271 @@ +"""Build ``tests/fixtures/stoch_realized_dispatch.json`` — the W8.1 +"realized dispatch" fixture: a stochastic (fan-at-first) INVEST solve +followed by a SEPARATE deterministic realized-dispatch solve that +consumes the stochastic solve's committed capacity as FIXED existing +capacity via the invest→dispatch handoff. + +This pins the W8 premise — that realized OPERATIONS can come from a +separate deterministic solve whose investments are fixed from the +stochastic solve — using ONLY the existing engine machinery (no engine +code change). Establishing that realized ops have a home is what later +makes W4 (hedge forecasts only) safe. Whether the realized-dispatch +pairing is author-declared (as here) or engine-automatic is a deferred +W4-era decision. + +Why the realized operational data is DETERMINISTIC (the W4 boundary) +-------------------------------------------------------------------- +Both solves share ONE scenario, so a parameter resolves to the SAME +value for both. A branch-DISTINCT (3-deep branch×time_start×time Map) +profile is only foldable by a genuinely stochastic solve; the +deterministic dispatch solve has no branch scaffolding, so it drops the +3-deep Map and wind collapses to a 0 upper_limit (empirically verified: +dispatch then maxes peaker and leaves 20 MW unserved at VOLL). Letting +the hedge forecasts diverge from the realized data the dispatch reads is +exactly the deferred W4 change ("hedge forecasts only"). W8.1 therefore +keeps the realized operational data deterministic (readable by both +solves) and proves the handoff + last-wins plumbing across a genuinely +stochastic solve and a deterministic one — which is what gives realized +ops a home and makes W4 safe later. + +Shape (small, single period, hand-calc-able) +-------------------------------------------- +One 3-step period ``p2035`` on an hourly timeline. + + * node ``elec`` — balance, high penalty (VOLL 10000), DETERMINISTIC + demand 100 MW/step (``inflow`` = -100). + * unit ``wind`` → ``elec`` — existing 100 MW, cost 0, DETERMINISTIC + ``upper_limit`` profile 0.40 → wind supply 40 → residual 60 (read by + both solves; shared by every forecast branch). + * unit ``peaker`` → ``elec`` — invest_total, virtual_unitsize 1 MW, + invest_cost 1 CUR/kW, discount_rate 0.05, lifetime 1 + (annuity 1000·CRF(0.05,1) = 1050), invest_max_total 150, + ``other_operational_cost`` 10 CUR/MWh on the output flow. + +Two solves (``model.solves`` ORDER matters — dispatch listed AFTER) +------------------------------------------------------------------ + 1. ``stoch_rd`` — GENUINELY stochastic, ``solve_mode=single_solve`` + (fan-at-first). ``stochastic_invest_method`` left at the DEFAULT + ``none`` (shared first-stage invest — one unambiguous ``v_invest``). + Branch weights SUM TO 1.0 per cohort: rlz 0.25 / high 0.375 / + low 0.375. The fan creates synthetic p2035_high / p2035_low + periods that own LP variables; every branch carries the SAME + (deterministic) realized operational data, so the shared first-stage + invest covers the per-step residual 60 MW → ``v_invest[peaker] = 60``. + 2. ``stoch_rd_realized_dispatch`` — DETERMINISTIC (no + ``stochastic_branches``, not in ``groupIncludeStochastics``), listed + AFTER, covering the SAME realized period p2035. No invest of its + own; it consumes ``stoch_rd``'s invested 60 MW peaker as FIXED + existing capacity through the handoff overlay (mirrors the proven + ``lh2_invest → lh2_dispatch`` precedent). Reading the deterministic + realized data (wind 0.40 → 40, demand 100 → residual 60) it + dispatches peaker at 60 MW. + +Last-wins: both solves realize p2035/t0001-t0003; ``stoch_rd`` is +emptied of its realized window (truncated in ``_native_run_model``) so +the deterministic dispatch solve OWNS the reported realized operations. + +Hand calculation (dispatch solve, assertion (d)) +------------------------------------------------ +Realized data: demand 100 MW/step, wind cap 0.40·100 = 40 MW/step (cost +0), peaker fills residual 60 MW/step (cost 10). The 3-step timeset +ANNUALIZES (``timeset_duration=3`` over ``years_represented=1`` → each +step represents 8760/3 h), so peaker energy = 60 MW · 8760 h and + operational objective = 60 MW · 8760 h · 10 CUR/MWh = 5 256 000 CUR +(the peaker's fixed existing capacity carries NO annuity in the dispatch +solve, so the dispatch objective is pure fuel). The test asserts this +exact value at rel=1e-9; the scaling-free physical split (peaker 60 MW, +wind 40 MW per step) is asserted separately. + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_realized_dispatch.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 4)] + + +def _pack(value: object, value_type: str) -> list: + """Serialize a parsed value into the fixture's [base64, type] pair.""" + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + """Top-level Spine Map payload.""" + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + """Nested (typed) Spine Map payload.""" + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + src = json.loads((FIXTURES / "stochastics.json").read_text()) + + spec: dict = { + key: src[key] + for key in ( + "entity_classes", + "parameter_value_lists", + "parameter_groups", + "parameter_definitions", + "parameter_types", + ) + } + + spec["alternatives"] = [ + ["init", "Model, solves and time structure (two-solve chain)"], + ["base", "System data (peaker invest + wind + elec demand)"], + ["stoch", "Stochastic branches + branch-varying wind profile"], + ] + spec["scenarios"] = [ + ["w8_realized_dispatch", False, + "Stochastic invest → deterministic realized-dispatch handoff"], + ] + spec["scenario_alternatives"] = [ + ["w8_realized_dispatch", "init", "base"], + ["w8_realized_dispatch", "base", "stoch"], + ["w8_realized_dispatch", "stoch", None], + ] + + spec["entities"] = [ + ["group", "stoch_g", None], + ["model", "flextool", None], + ["node", "elec", None], + ["profile", "wprof", None], + ["solve", "stoch_rd", None], + ["solve", "stoch_rd_realized_dispatch", None], + ["timeline", "y3h", None], + ["timeset", "ts1", None], + ["unit", "peaker", None], + ["unit", "wind", None], + ["group__unit", ["stoch_g", "wind"], None], + ["unit__outputNode", ["peaker", "elec"], None], + ["unit__outputNode", ["wind", "elec"], None], + ["unit__node__profile", ["wind", "elec", "wprof"], None], + ] + # node / unit classes are active_by_default=False. + spec["entity_alternatives"] = [ + ["node", ["elec"], "init", True], + ["unit", ["peaker"], "init", True], + ["unit", ["wind"], "init", True], + ] + + # ---- init: model / solves / time structure ------------------------ + # Single-period stochastic fan declared at p2035/t0001; weights SUM + # TO 1.0 per cohort (rlz 0.25 / high 0.375 / low 0.375). + stochastic_branches = _map([ + ("p2035", _nested([ + ("rlz", _nested([("t0001", _nested([("yes", 0.25)]))])), + ("high", _nested([("t0001", _nested([("no", 0.375)]))])), + ("low", _nested([("t0001", _nested([("no", 0.375)]))])), + ])), + ]) + # DETERMINISTIC realized operational data (base alternative), read by + # BOTH solves: wind upper_limit 0.40 → wind supply 40 → peaker + # residual 60. The forecast branches below carry the SAME realized + # operational data — they are a structurally genuine fan (synthetic + # p2035_high / p2035_low periods owning LP variables) but share the + # realized operational data, which is exactly the pre-W4 state. + # Authoring branch-DISTINCT operational data (a 3-deep + # branch×time_start×time Map) is NOT readable by the deterministic + # dispatch solve: it shares the scenario, is not genuinely + # stochastic, has no branch scaffolding, so the 3-deep profile is + # dropped and wind collapses to a 0 upper_limit (verified). Letting + # the hedge forecasts diverge from the realized data that the + # dispatch reads is precisely the deferred W4 change. + profile_base = _map([(t, 0.40) for t in STEPS]) + # Deterministic demand: 100 MW every step. + inflow = _map([(t, -100.0) for t in STEPS]) + + spec["parameter_values"] = [ + ["model", "flextool", "solves", + _pack({"value_type": "str", + "data": ["stoch_rd", "stoch_rd_realized_dispatch"]}, "array"), + "init"], + ["timeline", "y3h", "timestep_duration", + _pack(_map([(t, 1.0) for t in STEPS]), "map"), "init"], + ["timeset", "ts1", "timeline", _pack("y3h", "str"), "init"], + ["timeset", "ts1", "timeset_duration", + _pack(_map([("t0001", 3.0)]), "map"), "init"], + # ---- solve 1: stoch_rd (genuinely stochastic invest) ---------- + ["solve", "stoch_rd", "period_timeset", + _pack(_map([("p2035", "ts1")]), "map"), "init"], + ["solve", "stoch_rd", "realized_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), "init"], + ["solve", "stoch_rd", "invest_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), "init"], + ["solve", "stoch_rd", "realized_invest_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), "init"], + ["solve", "stoch_rd", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_rd", "years_represented", + _pack(_map([("p2035", 1.0)]), "map"), "init"], + # ---- solve 2: stoch_rd_realized_dispatch (deterministic) ------ + # No invest_periods ⇒ monolithic dispatch, no v_invest of its own. + # No stochastic_branches ⇒ is_genuinely_stochastic False ⇒ reads + # the realized (base) data. Listed AFTER ⇒ owns realized p2035. + ["solve", "stoch_rd_realized_dispatch", "period_timeset", + _pack(_map([("p2035", "ts1")]), "map"), "init"], + ["solve", "stoch_rd_realized_dispatch", "realized_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), "init"], + ["solve", "stoch_rd_realized_dispatch", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_rd_realized_dispatch", "years_represented", + _pack(_map([("p2035", 1.0)]), "map"), "init"], + # ---- base: system data ---------------------------------------- + ["node", "elec", "node_type", _pack("balance", "str"), "base"], + ["node", "elec", "penalty_up", _pack(10000.0, "float"), "base"], + ["node", "elec", "penalty_down", _pack(10000.0, "float"), "base"], + ["node", "elec", "inflow", _pack(inflow, "map"), "base"], + ["unit", "wind", "existing", _pack(100.0, "float"), "base"], + ["unit", "wind", "efficiency", _pack(1.0, "float"), "base"], + ["unit__node__profile", ["wind", "elec", "wprof"], "profile_method", + _pack("upper_limit", "str"), "base"], + ["profile", "wprof", "profile", _pack(profile_base, "map"), "base"], + ["unit", "peaker", "efficiency", _pack(1.0, "float"), "base"], + ["unit", "peaker", "existing", _pack(0.0, "float"), "base"], + ["unit", "peaker", "virtual_unitsize", _pack(1.0, "float"), "base"], + ["unit", "peaker", "invest_method", + _pack("invest_total", "str"), "base"], + ["unit", "peaker", "invest_cost", _pack(1.0, "float"), "base"], + ["unit", "peaker", "discount_rate", _pack(0.05, "float"), "base"], + ["unit", "peaker", "lifetime", _pack(1.0, "float"), "base"], + ["unit", "peaker", "invest_max_total", _pack(150.0, "float"), + "base"], + ["unit__outputNode", ["peaker", "elec"], "other_operational_cost", + _pack(10.0, "float"), "base"], + # ---- stoch: genuine stochastic fan (shared realized data) ------ + # include_stochastics + stochastic_branches make ``stoch_rd`` + # genuinely stochastic (synthetic p2035_high / p2035_low periods + # own LP variables). The branch-varying operational data is + # deliberately NOT overridden here (see profile_base) so the + # realized operational data stays deterministic and readable by + # the deterministic realized-dispatch solve. + ["group", "stoch_g", "include_stochastics", _pack("yes", "str"), + "stoch"], + ["solve", "stoch_rd", "stochastic_branches", + _pack(stochastic_branches, "map"), "stoch"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_realized_dispatch.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_realized_dispatch.json b/tests/fixtures/stoch_realized_dispatch.json new file mode 100644 index 000000000..a33b55b87 --- /dev/null +++ b/tests/fixtures/stoch_realized_dispatch.json @@ -0,0 +1,6263 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzQ=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solves and time structure (two-solve chain)" + ], + [ + "base", + "System data (peaker invest + wind + elec demand)" + ], + [ + "stoch", + "Stochastic branches + branch-varying wind profile" + ] + ], + "scenarios": [ + [ + "w8_realized_dispatch", + false, + "Stochastic invest \u2192 deterministic realized-dispatch handoff" + ] + ], + "scenario_alternatives": [ + [ + "w8_realized_dispatch", + "init", + "base" + ], + [ + "w8_realized_dispatch", + "base", + "stoch" + ], + [ + "w8_realized_dispatch", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_rd", + null + ], + [ + "solve", + "stoch_rd_realized_dispatch", + null + ], + [ + "timeline", + "y3h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "unit", + "peaker", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "peaker" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF9yZCIsICJzdG9jaF9yZF9yZWFsaXplZF9kaXNwYXRjaCJdfQ==", + "array" + ], + "init" + ], + [ + "timeline", + "y3h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "InkzaCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd_realized_dispatch", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd_realized_dispatch", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd_realized_dispatch", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_rd_realized_dispatch", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdXX0=", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTAwLjBdLCBbInQwMDAyIiwgLTEwMC4wXSwgWyJ0MDAwMyIsIC0xMDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "peaker", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + "other_operational_cost", + [ + "MTAuMA==", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ] + ] +} From e1643996b6c78abcba5d500966adc48efe001c22 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 00:07:21 +0300 Subject: [PATCH 70/87] =?UTF-8?q?feat(schema):=20add=20solve.dispatch=5Fre?= =?UTF-8?q?alization=5Fmethod=20(v75)=20=E2=80=94=20W4=20slice=201=20(W4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of W4 (de-scenario the realized weight), now unblocked by W8.1. Adds a new solve-level method `dispatch_realization_method` (schema v74→v75), value list {within_solve (default), separate_dispatch}, as the foundation for the engine-automatic realized-dispatch solve. Structural clone of the v70 stochastic_invest_method line. - within_solve (default) = today's behaviour: realization happens inside the solve's committed/jump region, no second solve → BYTE-PARITY (no engine branch keys off the new value yet). - separate_dispatch = the future auto-spawned realized-dispatch solve; HARD-REJECTED for now with a clear "not yet implemented (later slice)" FlexToolConfigError. The method is orthogonal to solve_mode and documented as the 2-stage seed of a future multi-stage market structure (W11); dispatch_realization (not just realization) to disambiguate from the investment-realization params (realized_invest_periods, stochastic_invest_method). Mechanics (mirror v70): migration _migrate_v75_dispatch_realization_method writes ONLY the value-list + parameter_definition (default within_solve) + solve_advanced group attach — NO parameter_value rows, so existing DBs stay byte-identical; reader + dispatch_realization_method_for accessor (absent/blank/unknown → within_solve); autoscale ('dispatch_realization_method','solve')=DIMENSIONLESS. Hard-reject guard _dispatch_realization_raw_guard reads the RAW authored value (so a typo can't silently normalize) with allowed-set explicitly {"", within_solve} — wired at BOTH v70 sites: the _validate_model_solve fail-fast pass AND the authoritative run() site (after the _roll_N suffix strip), so contained/chained/ rolling sub-solves authoring separate_dispatch raise rather than silently running the default path. Schema artifacts regenerated (definition/value-list only, verified no value rows): spinedb_schema.json, 10 canonical_databases/*.json, 7 tests/fixtures/*.json. Tests: test_v75_dispatch_realization_method.py (migration/default/group/value-list/ idempotent/master-template-verify) + test_w4a_dispatch_realization_guard.py (both guard sites, allowed-set, message). test_recourse_guards.py stub gains the new attribute (the shared _validate_model_solve guard reads it). Follow-ups: W4b (engine-automatic spawn of the realized-dispatch solve), W4c (realized-branch projection so the dispatch solve reads divergent realized data), W4d (realized operational weight→0, forecasts normalized), W4e (fixture/golden migration). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_orchestration.py | 47 ++++ flextool/engine_polars/_solve_config.py | 45 ++++ .../autoscale/_quantity_types.py | 2 + .../export_to_tabular/export_settings.yaml | 2 +- .../howto_aggregate_output.json | 27 ++- .../howto_connections.json | 27 ++- .../canonical_databases/howto_demand.json | 27 ++- .../howto_hydro_reservoir.json | 27 ++- .../howto_hydro_reservoir_with_pump.json | 27 ++- .../howto_non_sync_and_curtailment.json | 27 ++- .../howto_ramp_and_start_up.json | 27 ++- .../howto_stochastics.json | 27 ++- .../templates_examples.json | 27 ++- .../templates_time_settings_only.json | 27 ++- flextool/schemas/spinedb_schema.json | 18 +- flextool/update_flextool/__init__.py | 2 +- flextool/update_flextool/db_migration.py | 80 +++++++ tests/engine_polars/test_recourse_guards.py | 5 + .../test_w4a_dispatch_realization_guard.py | 201 ++++++++++++++++++ tests/fixtures/branch2_parent_period.json | 27 ++- tests/fixtures/case14_dc_power_flow.json | 27 ++- tests/fixtures/h2_trade_parity.json | 27 ++- tests/fixtures/lh2_three_region.json | 27 ++- tests/fixtures/stochastics.json | 27 ++- tests/fixtures/stochastics_pbt_inflow.json | 27 ++- tests/fixtures/tests.json | 27 ++- .../test_v75_dispatch_realization_method.py | 201 ++++++++++++++++++ 27 files changed, 1042 insertions(+), 20 deletions(-) create mode 100644 tests/engine_polars/test_w4a_dispatch_realization_guard.py create mode 100644 tests/update_flextool/test_v75_dispatch_realization_method.py diff --git a/flextool/engine_polars/_orchestration.py b/flextool/engine_polars/_orchestration.py index a38a6e80b..4f6e5052b 100644 --- a/flextool/engine_polars/_orchestration.py +++ b/flextool/engine_polars/_orchestration.py @@ -1587,6 +1587,37 @@ def _stochastic_invest_raw_guard(solve_cfg, base_name: str) -> None: ) +def _dispatch_realization_raw_guard(solve_cfg, base_name: str) -> None: + """W4 Slice 1 Guard — hard-reject a non-default dispatch_realization_method. + + ``solve.dispatch_realization_method`` is the 2-stage seed of a future + multi-stage market structure. Only ``'within_solve'`` (the schema + default, today's behaviour) is implemented; ``'separate_dispatch'`` — + splitting realization into a dedicated downstream dispatch stage — has + no engine machinery yet (it lands in a later slice) and is HARD-REJECTED + here. + + The guard reads the RAW authored value rather than the resolver's + output: :meth:`SolveConfig.dispatch_realization_method_for` silently + collapses any unrecognised value (including ``'separate_dispatch'``) to + ``'within_solve'``, so an authoring-time check must inspect the raw dict + and reject anything outside the allowed set ``{"", "within_solve"}``. + That rejects ``'separate_dispatch'`` AND any typo / unknown value. + Blank / absent tolerate-as-default (matches the resolver). No-op when + the solve authors nothing. + """ + raw = solve_cfg.dispatch_realization_method.get(base_name) + if raw is None: + return + val = str(raw).strip().lower() + if val not in ("", "within_solve"): + raise FlexToolConfigError( + f"Solve '{base_name}' sets dispatch_realization_method='{raw}' — " + f"'separate_dispatch' is not yet implemented (lands in a later " + f"slice); use 'within_solve'." + ) + + def _validate_model_solve(state: RunnerState) -> list[str]: """Validate ``state.solve.model_solve`` and return the solve list. @@ -1614,6 +1645,14 @@ def _validate_model_solve(state: RunnerState) -> list[str]: # ``run()``. for _s in solves: _stochastic_invest_raw_guard(state.solve, _s) + # W4 Slice 1 Guard (fail-fast pass): reject a non-default + # dispatch_realization_method ('separate_dispatch' not yet + # implemented) for the model's top-level solves before any solve + # runs. Like the stochastic_invest pass, this only sees the + # top-level ``solves`` array (not solves reached via + # ``contains_solves`` recursion / rolling), so it is a convenience — + # the authoritative per-solve guard lives at the top of ``run()``. + _dispatch_realization_raw_guard(state.solve, _s) return solves @@ -2394,6 +2433,14 @@ def run(self, complete_solve_name: str) -> int: # base-name derivation below (``:2486``). _guard_base = re.sub(r"_roll_\d+$", "", complete_solve_name) _stochastic_invest_raw_guard(state.solve, _guard_base) + # W4 Slice 1 Guard (authoritative): reject a non-default + # dispatch_realization_method for any solve that RUNS — + # including each roll of a rolling solve and each contained/ + # chained solve — before building its FlexData/LP. Omitting + # this site would let a contained/chained solve authoring + # 'separate_dispatch' silently normalize to 'within_solve' via + # the resolver. 'separate_dispatch' is not yet implemented. + _dispatch_realization_raw_guard(state.solve, _guard_base) # Cross-level eviction — release any EXHAUSTED prior solve-level's # live HiGHS instance + flex_data_provider BEFORE this solve builds # its FlexData/LP, so two level footprints never coexist (the DES diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 908100b15..3f8ed41ef 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -248,6 +248,7 @@ def __init__( benders_in_out_weight: dict | None = None, scaling: dict | None = None, stochastic_invest_method: dict | None = None, + dispatch_realization_method: dict | None = None, ) -> None: # Base fields (read directly from DB in load_from_db). self.model = model @@ -319,6 +320,15 @@ def __init__( if stochastic_invest_method is not None else {} ) + # v75 per-solve dispatch-realization method (solve-name -> + # "within_solve"/"separate_dispatch"); only authored solves appear. + # Resolved at access time via :meth:`dispatch_realization_method_for` + # (absent -> "within_solve"). + self.dispatch_realization_method: dict = ( + dispatch_realization_method + if dispatch_realization_method is not None else {} + ) + # Computed fields — populated by load_from_db after construction. self.roll_counter: dict[str, int] = {} self.timesets_used_by_solves: defaultdict = defaultdict(list) @@ -555,6 +565,15 @@ def load_from_db( mode=DictMode.DICT, ) + # v75 per-solve dispatch-realization method. Only solves that + # explicitly author solve.dispatch_realization_method appear; absent + # solves resolve to "within_solve" via + # ``dispatch_realization_method_for``. + dispatch_realization_method: dict = params_to_dict( + db=db, cl="solve", par="dispatch_realization_method", + mode=DictMode.DICT, + ) + # rolling_times: assemble per-solve [jump, horizon, duration]. rolling_duration: dict = params_to_dict( db=db, cl="solve", par="rolling_duration", mode=DictMode.DICT @@ -663,6 +682,7 @@ def _opt_int(raw: dict, key: str) -> int | None: benders_in_out_weight=benders_in_out_weight, scaling=scaling, stochastic_invest_method=stochastic_invest_method, + dispatch_realization_method=dispatch_realization_method, ) # Computed fields — loading order MUST be preserved exactly. @@ -953,6 +973,31 @@ def stochastic_invest_method_for(self, solve_name: str) -> str: value = str(raw).strip().lower() return value if value in ("none", "recourse") else "none" + def dispatch_realization_method_for(self, solve_name: str) -> str: + """Resolve ``solve.dispatch_realization_method`` for *solve_name*. + + Returns the normalised lower-case mode (``"within_solve"`` or + ``"separate_dispatch"``). Absent / blank / unrecognised -> + ``"within_solve"`` (the schema default, byte-identical prior + behaviour). Authoring case is ignored. + + This knob is the 2-stage seed of a future multi-stage market + structure: ``"within_solve"`` resolves branch realization inside a + single optimisation (today's behaviour); ``"separate_dispatch"`` + will split realization into a dedicated downstream dispatch stage + (a second market-clearing pass over the revealed branch) — not yet + implemented, hard-rejected by the W4 Slice 1 guard until a later + slice lands the machinery. There is no run-time override for this + knob, so absent resolves to the concrete default ``"within_solve"``. + """ + raw = self.dispatch_realization_method.get(solve_name) + if raw is None: + return "within_solve" + value = str(raw).strip().lower() + return value if value in ( + "within_solve", "separate_dispatch" + ) else "within_solve" + def benders_config_for(self, solve_name: str) -> tuple[int, float, float]: """Resolve ``(max_iter, tol, in_out_weight)`` for *solve_name*. diff --git a/flextool/engine_polars/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index bad14c992..0cbb4cf69 100644 --- a/flextool/engine_polars/autoscale/_quantity_types.py +++ b/flextool/engine_polars/autoscale/_quantity_types.py @@ -543,6 +543,8 @@ class QuantityType(Enum): # [4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the... ('stochastic_invest_method', 'solve'): QuantityType.DIMENSIONLESS, # Opt-in mode string (none/recourse) for how stochastic branches participate in investment; a mode carries no physical unit. + ('dispatch_realization_method', 'solve'): QuantityType.DIMENSIONLESS, + # Opt-in mode string (within_solve/separate_dispatch) for how stochastic branches are realized into dispatch; a mode carries no physical unit. ('storage_binding_method', 'node'): QuantityType.DIMENSIONLESS, # Choice how the storage state will be maintained over discontinuos timelines. The default value 'bind_forward_only' will bind forward over an... ('storage_nested_fix_method', 'node'): QuantityType.DIMENSIONLESS, diff --git a/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 183803f9c..18039322d 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -85,7 +85,7 @@ split_params: "timeline_hole_multiplier", "decomposition", "benders_tolerance", "benders_max_iter", "benders_in_out_weight", - "stochastic_invest_method"] + "stochastic_invest_method", "dispatch_realization_method"] solve_period: params: ["period_timeset", "years_represented", "realized_periods", "invest_periods", "realized_invest_periods", "fix_storage_periods", diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index 8e521d0fe..76ded2580 100644 --- a/flextool/schemas/canonical_databases/howto_aggregate_output.json +++ b/flextool/schemas/canonical_databases/howto_aggregate_output.json @@ -1594,7 +1594,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -2202,6 +2202,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4941,6 +4952,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_connections.json b/flextool/schemas/canonical_databases/howto_connections.json index 9448d3d1b..ed0903b0c 100644 --- a/flextool/schemas/canonical_databases/howto_connections.json +++ b/flextool/schemas/canonical_databases/howto_connections.json @@ -1229,7 +1229,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1848,6 +1848,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4595,6 +4606,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_demand.json b/flextool/schemas/canonical_databases/howto_demand.json index 38e2cc266..249f174fc 100644 --- a/flextool/schemas/canonical_databases/howto_demand.json +++ b/flextool/schemas/canonical_databases/howto_demand.json @@ -1145,7 +1145,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1764,6 +1764,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4511,6 +4522,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json index 8a78c157e..1e1e1f7ea 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir.json @@ -1398,7 +1398,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -2017,6 +2017,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4764,6 +4775,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json index a59b5b4be..d1c25a549 100644 --- a/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +++ b/flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json @@ -1247,7 +1247,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1866,6 +1866,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4613,6 +4624,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json index 103256eee..40f4ca7d7 100644 --- a/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +++ b/flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json @@ -1276,7 +1276,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1884,6 +1884,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4631,6 +4642,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json index 99c9bede4..d53331c00 100644 --- a/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +++ b/flextool/schemas/canonical_databases/howto_ramp_and_start_up.json @@ -1216,7 +1216,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1835,6 +1835,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4582,6 +4593,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/howto_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index 1cab4e5a4..0528860ec 100644 --- a/flextool/schemas/canonical_databases/howto_stochastics.json +++ b/flextool/schemas/canonical_databases/howto_stochastics.json @@ -1337,7 +1337,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1956,6 +1956,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4681,6 +4692,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index 40a9c2679..d63278731 100644 --- a/flextool/schemas/canonical_databases/templates_examples.json +++ b/flextool/schemas/canonical_databases/templates_examples.json @@ -2519,7 +2519,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -3127,6 +3127,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -5894,6 +5905,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/canonical_databases/templates_time_settings_only.json b/flextool/schemas/canonical_databases/templates_time_settings_only.json index 785c013ea..2a29c5123 100644 --- a/flextool/schemas/canonical_databases/templates_time_settings_only.json +++ b/flextool/schemas/canonical_databases/templates_time_settings_only.json @@ -1095,7 +1095,7 @@ "model", "version", [ - "74", + "75", "float" ], null, @@ -1711,6 +1711,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "\"within_solve\"", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -4442,6 +4453,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index 2009e8339..7444416ae 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -365,6 +365,14 @@ "decomposition_schemes", "benders" ], + [ + "dispatch_realization_methods", + "within_solve" + ], + [ + "dispatch_realization_methods", + "separate_dispatch" + ], [ "energy_margin_methods", "none" @@ -1643,7 +1651,7 @@ [ "model", "version", - 74.0, + 75.0, null, "Contains database version information.", "model" @@ -2192,6 +2200,14 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + "within_solve", + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/flextool/update_flextool/__init__.py b/flextool/update_flextool/__init__.py index 192ee69d0..84f4c2053 100644 --- a/flextool/update_flextool/__init__.py +++ b/flextool/update_flextool/__init__.py @@ -3,7 +3,7 @@ #: The current FlexTool database schema version. #: This is the single source of truth — all other modules import from here. #: Defined before submodule imports to avoid circular-import issues. -FLEXTOOL_DB_VERSION: int = 74 +FLEXTOOL_DB_VERSION: int = 75 from flextool.update_flextool.self_update import update_flextool, ensure_runtime_files # noqa: E402 # FLEXTOOL_DB_VERSION must precede submodule imports to break circular dep from flextool.update_flextool.db_migration import migrate_database # noqa: E402 # see FLEXTOOL_DB_VERSION ordering note above diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 7eb61a846..51e4976d7 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -1771,6 +1771,8 @@ def _is_yes(pv): _migrate_v73_construction_lead_time(db) elif next_version == 74: _migrate_v74(db) + elif next_version == 75: + _migrate_v75_dispatch_realization_method(db) else: print("Version invalid") last_completed_version = next_version @@ -4214,6 +4216,84 @@ def _migrate_v74(db) -> None: ) +def _migrate_v75_dispatch_realization_method(db) -> None: + """Add the solve.dispatch_realization_method opt-in (v74 -> v75). + + Selects how the stochastic branches of a solve are realized into a + committed dispatch. This is the 2-stage seed of a future multi-stage + market structure: ``'within_solve'`` (default, byte-identical to prior + behaviour) keeps realization inside the single optimisation — the + branches and their realized periods are resolved within one solve, as + today. ``'separate_dispatch'`` will split realization into a dedicated + downstream dispatch stage (a second market-clearing pass over the + revealed branch), the first step toward a multi-stage sequential-market + structure. + + W4 Slice 1 ships only the schema + the hard-reject guard: flag-on + (``'separate_dispatch'``) is HARD-REJECTED until the separate-dispatch + machinery lands (slices W4b/c/d — building the downstream dispatch + stage). Bound to a new 'dispatch_realization_methods' value list; + grouped under 'solve_advanced' (created if the DB lacks it). This is a + structural clone of the v70 ``stochastic_invest_method`` line. + """ + add_value_list_manual(db, [ + ["dispatch_realization_methods", "within_solve"], + ["dispatch_realization_methods", "separate_dispatch"], + ]) + + default_val, default_type = to_database("within_solve") + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="dispatch_realization_method", + default_value=default_val, + default_type=default_type, + parameter_value_list_name="dispatch_realization_methods", + description=( + "How the stochastic branches of a solve are realized into a " + "committed dispatch. 'within_solve' (default) keeps realization " + "inside the single optimisation — branches and their realized " + "periods are resolved within one solve, byte-identical to prior " + "behaviour. 'separate_dispatch' splits realization into a " + "dedicated downstream dispatch stage (a second market-clearing " + "pass over the revealed branch); this is the 2-stage seed of a " + "future multi-stage sequential-market structure and is not yet " + "implemented (lands in a later slice)." + ), + ) + # Attach to the 'solve_advanced' parameter group, creating it if the + # DB lacks it. Every parameter must belong to a parameter_group so it + # is not dropped from group-filtered tabular exports (v69 invariant, + # guarded by tests/test_parameter_group_coverage.py), and solve_advanced + # is this param's canonical home in the schema. Real DBs always have it + # (created in v44, recoloured in v45), but a DB built from scratch at a + # version above v44 — e.g. the minimal fixtures the migration tests seed + # — never ran the v44 step and so carries no parameter_groups. Use the + # non-raising get_item (db.item raises SpineDBAPIError when absent) and + # get-or-create; the create only fires on such group-less DBs and mirrors + # v44's colour/priority for solve_advanced (post-v45 colour b56f6f). + if not db.get_item("parameter_group", name="solve_advanced"): + db.add_update_item( + "parameter_group", + name="solve_advanced", + color="b56f6f", + priority=87, + ) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="dispatch_realization_method", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v75: added solve.dispatch_realization_method " + "(dispatch_realization_methods: within_solve/separate_dispatch, " + "default within_solve); 2-stage seed of a future multi-stage market " + "structure. 'separate_dispatch' hard-rejected until a later slice." + ) + + def _migrate_v52_solver_selection(db) -> None: """Add per-solve solver-selection parameters (Phase 1 multi-solver). diff --git a/tests/engine_polars/test_recourse_guards.py b/tests/engine_polars/test_recourse_guards.py index d5489a8bb..302a8cb35 100644 --- a/tests/engine_polars/test_recourse_guards.py +++ b/tests/engine_polars/test_recourse_guards.py @@ -71,6 +71,11 @@ def _solve_config_with_flag(model_solve, flag_map) -> SolveConfig: sc = object.__new__(SolveConfig) sc.model_solve = model_solve sc.stochastic_invest_method = flag_map + # ``_validate_model_solve`` also runs the sibling W4a + # dispatch_realization_method fail-fast guard, which reads this dict; + # keep it empty (all-default 'within_solve') so that guard is inert and + # only the stochastic_invest guard is under test here. + sc.dispatch_realization_method = {} return sc diff --git a/tests/engine_polars/test_w4a_dispatch_realization_guard.py b/tests/engine_polars/test_w4a_dispatch_realization_guard.py new file mode 100644 index 000000000..0a073a84b --- /dev/null +++ b/tests/engine_polars/test_w4a_dispatch_realization_guard.py @@ -0,0 +1,201 @@ +"""W4 Slice 1 (W4a) — dispatch_realization_method hard-reject guard. + +``solve.dispatch_realization_method`` is the 2-stage seed of a future +multi-stage market structure. Only ``'within_solve'`` (the schema +default, today's behaviour) is implemented; ``'separate_dispatch'`` — a +dedicated downstream dispatch stage — has no engine machinery yet and is +HARD-REJECTED until a later slice. + +The guard (:func:`_dispatch_realization_raw_guard`) reads the RAW authored +value and its allowed set is EXPLICITLY ``{"", "within_solve"}`` — it +raises :class:`FlexToolConfigError` for ``'separate_dispatch'`` AND for any +unrecognised value. The normalizing accessor +(:meth:`SolveConfig.dispatch_realization_method_for`) would silently +collapse those to ``'within_solve'``, which is why the guard inspects the +raw dict. + +The guard is wired at BOTH v70-style call sites: + +* ``_validate_model_solve`` — a fail-fast pass over the model's top-level + solves. +* the authoritative ``run(complete_solve_name)`` site, which strips the + ``_roll_\\d+$`` suffix and fires for every solve that RUNS (including + each roll of a rolling solve and each contained/chained sub-solve). The + roll-suffix-strip composition is exercised here directly (mirrors the + v70 ``test_g1_rolling_suffix_strip_composition``). +""" +from __future__ import annotations + +import dataclasses +import re + +import pytest + +from flextool.engine_polars._solve_config import SolveConfig +from flextool.engine_polars._solve_state import FlexToolConfigError + + +# --------------------------------------------------------------------------- +# A bare SolveConfig carrying only the two fields the guard/accessor read — +# ``model_solve`` and ``dispatch_realization_method``. +# --------------------------------------------------------------------------- + + +def _solve_config_with_method(model_solve, method_map) -> SolveConfig: + sc = object.__new__(SolveConfig) + sc.model_solve = model_solve + sc.dispatch_realization_method = method_map + # ``_validate_model_solve`` also runs the sibling stochastic_invest + # fail-fast pass, which reads this dict; keep it empty (all-default) so + # that guard is inert and only the dispatch guard is under test. + sc.stochastic_invest_method = {} + return sc + + +# =========================================================================== +# Accessor normalisation — absent / blank / typo / separate_dispatch +# =========================================================================== + + +def test_resolver_normalisation(): + sc = _solve_config_with_method( + {"m": ["s"]}, + { + "s": "within_solve", + "loud": "WITHIN_SOLVE", + "sep": "separate_dispatch", + "blank": "", + "junk": "xyz", + }, + ) + assert sc.dispatch_realization_method_for("s") == "within_solve" + # case-fold + assert sc.dispatch_realization_method_for("loud") == "within_solve" + # the non-default listed value normalises cleanly (resolver recognises + # it; the GUARD is what rejects it — see below) + assert ( + sc.dispatch_realization_method_for("sep") == "separate_dispatch" + ) + # blank / typo / absent collapse to the default + assert sc.dispatch_realization_method_for("blank") == "within_solve" + assert sc.dispatch_realization_method_for("junk") == "within_solve" + assert sc.dispatch_realization_method_for("absent") == "within_solve" + + +# =========================================================================== +# Guard — hard-reject separate_dispatch + any unrecognised value +# =========================================================================== + + +def test_raw_guard_rejects_separate_dispatch_and_typos(): + """The guard inspects the RAW authored value (the resolver would + silently collapse a typo to 'within_solve' and happily pass + 'separate_dispatch' to downstream machinery that does not exist yet), + so 'separate_dispatch' AND any unrecognised value raise — while + 'within_solve' / blank / absent do not.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + for bad in ("separate_dispatch", "SEPARATE_DISPATCH", "wihtin_solve", + "expected_value"): + sc = _solve_config_with_method({"m": ["s"]}, {"s": bad}) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "s") + assert bad in str(exc.value) + + # Accepted / tolerated raw values → no raise. + for ok in ("within_solve", "WITHIN_SOLVE", "", " within_solve "): + sc = _solve_config_with_method({"m": ["s"]}, {"s": ok}) + _dispatch_realization_raw_guard(sc, "s") # no raise + # Absent → no raise. + _dispatch_realization_raw_guard( + _solve_config_with_method({"m": ["s"]}, {}), "s") + + +def test_separate_dispatch_message_names_solve_and_within_solve(): + """The reject message names the solve and points at 'within_solve'.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method({"m": ["my_solve"]}, + {"my_solve": "separate_dispatch"}) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "my_solve") + msg = str(exc.value) + assert "my_solve" in msg + assert "not yet implemented" in msg + assert "within_solve" in msg + + +# --- Call site 1: _validate_model_solve fail-fast pass ------------------ + + +def test_validate_model_solve_rejects_separate_dispatch(): + """The fail-fast pass over the model's top-level solves raises on + 'separate_dispatch' before any solve runs.""" + from flextool.engine_polars._orchestration import _validate_model_solve + + sc = _solve_config_with_method({"m": ["s_ok", "s_sep"]}, + {"s_sep": "separate_dispatch"}) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + with pytest.raises(FlexToolConfigError): + _validate_model_solve(state) + + +def test_validate_model_solve_passes_within_solve_and_absent(): + """'within_solve' and absent (all-default) pass and return the solve + list unchanged.""" + from flextool.engine_polars._orchestration import _validate_model_solve + + # Explicit within_solve on one solve, the other unset. + sc = _solve_config_with_method({"m": ["s1", "s2"]}, + {"s1": "within_solve"}) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + assert _validate_model_solve(state) == ["s1", "s2"] + + # All default (absent). + sc2 = _solve_config_with_method({"m": ["s1", "s2"]}, {}) + state2 = dataclasses.make_dataclass("S", ["solve"])(solve=sc2) + assert _validate_model_solve(state2) == ["s1", "s2"] + + +# --- Call site 2: authoritative run() site (roll-suffix strip) ---------- + + +def test_run_site_rejects_rolling_child_of_separate_dispatch(): + """The authoritative ``run()`` guard strips the ``_roll_N`` suffix + before the raw-dict lookup, so a rolling / contained child of a + separate_dispatch solve is rejected — this mirrors the exact code the + run() site executes (``re.sub`` strip -> ``_dispatch_realization_raw_guard``), + so a contained/chained/rolling sub-solve cannot silently normalize to + 'within_solve'.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method({"m": ["my_solve"]}, + {"my_solve": "separate_dispatch"}) + # Rolling child name as it reaches run(): "_roll_". + complete_solve_name = "my_solve_roll_2" + guard_base = re.sub(r"_roll_\d+$", "", complete_solve_name) + assert guard_base == "my_solve" + with pytest.raises(FlexToolConfigError): + _dispatch_realization_raw_guard(sc, guard_base) + + +def test_run_site_passes_within_solve_rolling_child(): + """A rolling child of a within_solve (or unset) solve passes the + run()-site guard.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method({"m": ["my_solve"]}, + {"my_solve": "within_solve"}) + guard_base = re.sub(r"_roll_\d+$", "", "my_solve_roll_7") + _dispatch_realization_raw_guard(sc, guard_base) # no raise + # Unset base → no raise. + sc2 = _solve_config_with_method({"m": ["my_solve"]}, {}) + _dispatch_realization_raw_guard(sc2, guard_base) # no raise diff --git a/tests/fixtures/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 65459298c..6aa793b0b 100644 --- a/tests/fixtures/branch2_parent_period.json +++ b/tests/fixtures/branch2_parent_period.json @@ -697,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2577,7 +2591,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -3343,6 +3357,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index ec9ed333a..b4c8ba8e1 100644 --- a/tests/fixtures/case14_dc_power_flow.json +++ b/tests/fixtures/case14_dc_power_flow.json @@ -1735,6 +1735,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -3629,7 +3643,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -4384,6 +4398,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/h2_trade_parity.json b/tests/fixtures/h2_trade_parity.json index e4e43bed7..1ff2f397a 100644 --- a/tests/fixtures/h2_trade_parity.json +++ b/tests/fixtures/h2_trade_parity.json @@ -6843,6 +6843,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -8723,7 +8737,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -9478,6 +9492,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/lh2_three_region.json b/tests/fixtures/lh2_three_region.json index 591ed881d..8a3123384 100644 --- a/tests/fixtures/lh2_three_region.json +++ b/tests/fixtures/lh2_three_region.json @@ -2740,6 +2740,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -4571,7 +4585,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -5326,6 +5340,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index c1f40cd18..df9189cda 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -697,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2577,7 +2591,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -3343,6 +3357,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index b1649904b..c408cf03c 100644 --- a/tests/fixtures/stochastics_pbt_inflow.json +++ b/tests/fixtures/stochastics_pbt_inflow.json @@ -697,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2577,7 +2591,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -3343,6 +3357,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/fixtures/tests.json b/tests/fixtures/tests.json index d024e96fd..189eecb8b 100644 --- a/tests/fixtures/tests.json +++ b/tests/fixtures/tests.json @@ -1686,6 +1686,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -3517,7 +3531,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -4272,6 +4286,17 @@ "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", "solve_advanced" ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", diff --git a/tests/update_flextool/test_v75_dispatch_realization_method.py b/tests/update_flextool/test_v75_dispatch_realization_method.py new file mode 100644 index 000000000..027068649 --- /dev/null +++ b/tests/update_flextool/test_v75_dispatch_realization_method.py @@ -0,0 +1,201 @@ +"""Tests for the v75 database migration (W4 Slice 1 / W4a). + +v75 adds ``solve.dispatch_realization_method`` — the opt-in enum selecting +how the stochastic branches of a solve are realized into a committed +dispatch (the 2-stage seed of a future multi-stage market structure): + +1. Creates the value list ``dispatch_realization_methods`` == + {within_solve, separate_dispatch}. +2. Adds the ``solve.dispatch_realization_method`` definition (default + ``"within_solve"``) bound to that value list, grouped under + ``solve_advanced``. + +``'within_solve'`` (default) is byte-identical to prior behaviour; +``'separate_dispatch'`` is hard-rejected by the W4a guard until a later +slice lands the separate-dispatch machinery. The engine-side +read/resolver + guard are covered by +``tests/engine_polars/test_w4a_dispatch_realization_guard.py``. + +This migration is a structural clone of the v70 +``stochastic_invest_method`` line. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from spinedb_api import DatabaseMapping, from_database, import_data + +from flextool.update_flextool import FLEXTOOL_DB_VERSION +from flextool.update_flextool.db_migration import migrate_database +from flextool.update_flextool.sync_master_json_template import ( + sync_master_template, +) + +from tests.db_utils import json_to_db + +TEST_DIR = Path(__file__).resolve().parents[1] +FIXTURES_DIR = TEST_DIR / "fixtures" + + +def test_v75_version_constant_is_at_least_75() -> None: + """The engine must report a schema version >= 75 — the + dispatch_realization_method lower bound. Later migrations keep raising + the constant, so an exact-equality assertion would regress. + """ + assert FLEXTOOL_DB_VERSION >= 75 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON, migrate it to HEAD, and return an open + mapping carrying the v75 parameter.""" + db_path = tmp_path / "lh2.sqlite" + url = json_to_db(FIXTURES_DIR / "lh2_three_region.json", db_path) + migrate_database(url) + db = DatabaseMapping(url, create=False) + db.fetch_all() + return db + + +def _db_version(url: str) -> int: + """Read the migrated ``model.version`` parameter-definition default.""" + with DatabaseMapping(url, create=False) as db: + sq = db.object_parameter_definition_sq + row = ( + db.query(sq) + .filter(sq.c.object_class_name == "model") + .filter(sq.c.parameter_name == "version") + .one_or_none() + ) + assert row is not None, "model.version parameter definition missing" + return int(from_database(row.default_value, row.default_type)) + + +# --- M1 / M3: the migration adds the definition + value list ------------ + + +def test_dispatch_realization_method_definition_present(tmp_path: Path) -> None: + """``solve.dispatch_realization_method`` exists, defaults to + ``"within_solve"``, is bound to ``dispatch_realization_methods`` and + grouped ``solve_advanced``.""" + db = _migrated_db(tmp_path) + try: + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="dispatch_realization_method", + ) + assert pdef, "dispatch_realization_method not added by migration" + default = from_database(pdef["default_value"], pdef["default_type"]) + assert default == "within_solve" + assert ( + pdef["parameter_value_list_name"] + == "dispatch_realization_methods" + ) + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_dispatch_realization_methods_value_list(tmp_path: Path) -> None: + """``dispatch_realization_methods`` == {within_solve, separate_dispatch}.""" + db = _migrated_db(tmp_path) + try: + values = { + from_database(v["value"], v["type"]) + for v in db.get_list_value_items() + if v["parameter_value_list_name"] + == "dispatch_realization_methods" + } + assert values == {"within_solve", "separate_dispatch"} + finally: + db.close() + + +def _build_v74_solve_db(url: str) -> None: + """Create a minimal v74 DB (``model`` + ``solve`` classes) carrying no + ``dispatch_realization_method`` — so the v75 block is the ONLY path that + can add it. + + Built from scratch (never a checked-in .sqlite, never the + ``test_fixtures`` corpus): that corpus is regenerated to HEAD (>= v75), + so loading one would start AT v75 and never exercise the v75 block. + ``model.version`` default is pinned to 74 so ``migrate_database(..., + up_to=75)`` runs only the v75 step, keeping this a faithful v75 test + even as later migrations extend the chain. The v75 step is designed + to tolerate such minimal DBs (it creates ``solve_advanced`` if absent). + """ + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 74.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session( + "Seed v74 DB (model + solve, no dispatch realization method)" + ) + + +def test_migration_reaches_v75_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v75 DB migrates to exactly v75, and re-running the v75 step + is a no-op that keeps the parameter present — the + ``add_value_list_manual`` / ``add_update_item`` helpers tolerate + re-adds. + + Seeded from scratch at v74 (see ``_build_v74_solve_db``) so it starts + genuinely BELOW v75 and exercises the v75 migration block from below, + independent of the auto-migrated ``test_fixtures`` corpus. + """ + db_path = tmp_path / "v74_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v74_solve_db(url) + start_version = _db_version(url) + assert start_version < 75, ( + "seed DB must start below v75 so the v75 block is exercised" + ) + + migrate_database(url, up_to=75) + assert _db_version(url) == 75 + + # Idempotent re-run of the v75 step — no crash, parameter still present. + migrate_database(url, up_to=75) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="dispatch_realization_method", + ) + assert pdef, "parameter lost on idempotent re-migration" + assert ( + from_database(pdef["default_value"], pdef["default_type"]) + == "within_solve" + ) + + +# --- M2: the committed master template is up to date -------------------- + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="sync_master_template runs migrate_database, which leaves a sqlite " + "engine handle open; the TemporaryDirectory cleanup then hits WinError 32 " + "on Windows. The same verification runs on the ubuntu-only template-check " + "CI job (sync_master_json_template --verify).", +) +def test_master_template_up_to_date() -> None: + """``sync_master_template(verify_only=True)`` returns True — the + spinedb_schema.json regen (with the v75 parameter) was committed. + This is the same assertion CI makes; pinning it locally catches a + forgotten regen.""" + assert sync_master_template(verify_only=True) is True From 09cbc8ffe63266cf75c7870a07e3073bc9652a37 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 09:16:53 +0300 Subject: [PATCH 71/87] feat(stochastic): auto-spawn realized-dispatch solve on separate_dispatch (W4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W4 slice 2. Makes solve.dispatch_realization_method=separate_dispatch (schema added in W4a, previously hard-rejected) actually work: when a single TOP-LEVEL solve authors it, the engine automatically registers a deterministic realized-dispatch sibling __realized_dispatch that takes the FULL override path — so it is indistinguishable from the author-declared dispatch solve W8.1 proved. Generalizes W8.1 from author-declared to engine-synthesized. Mechanism (dual in-memory registration, no DB mutation; prototyped end-to-end): - SpineDbReader.register_derived_solve: mints a FRESH entity id and injects the sibling into _entities_by_class/_entity_by_id, cloning only the 4 dispatch- defining params (period_timeset/realized_periods/years_represented/solve_mode) into _param_rows under the fresh eid — so _solve_in_spine(sibling) is True and it routes the full apply_derived_a..g path (feature passes run → no synthetic- path silent drop). Reusing the parent eid would map rows back to the parent and break _solve_periods(sibling) → avoided. - SolveConfig.spawn_realized_dispatch_sibling: inserts the sibling right after the parent in model_solve (positional → last-wins + handoff), copies the timeline scaffolding, SETs realized_periods[sibling]=parent's, and DROPs invest/ stochastic/benders/decomposition/non_anticipativity/fix_storage/contains_solves/ dispatch_realization_method. scaling/solver/decomposition are deliberately NOT copied: run() resolves those from the separate cascade SolveConfig, so the sibling uses engine defaults — correct (monolithic) for a deterministic dispatch. - _spawn_realized_dispatch_siblings: called once in _drive_cascade after the reader is built, before native_run_model, on runner.state.solve. Guard (_dispatch_realization_raw_guard, both wired sites) narrowed: accept separate_dispatch ONLY for a single top-level single_solve (in model_solve, not rolling, no contains_solves, not nested); reject the rest with a reason; keep within_solve/absent byte-parity; sibling no-ops at the run()-site. Collision on a pre-existing __realized_dispatch raises. Storage: the sibling drops fix_storage_periods, so separate_dispatch is rejected (positive guard, loud) for any parent authoring a non-empty fix_storage_periods — storage pinning in the realized-dispatch solve is a later slice. Multi-period storage WITHOUT fix_storage rides the monolithic single_solve balance + node-level storage params (preserved on the full path). Reuses the W8.1 invest→dispatch handoff + realized last-wins + output-union unchanged. Byte-parity: spawn/guard are no-ops unless a solve authors separate_dispatch, so existing within_solve/absent fixtures are untouched. Tests: test_w4b_auto_dispatch.py reproduces the 4 W8.1 oracles against the auto-spawned sibling (objective 5,256,000 at rel=1e-9; no v_invest, 60 MW handed as fixed existing; last-wins empties the parent realized window) + a reserve full-path proof (v_reserve present) + register/spawn/collision/storage-guard unit tests. W4a guard tests rewritten to the narrowed semantics. W8.1 author-declared test kept as the baseline. Deferred: W4b-rolling (per-roll spawn), W4c (realized-branch projection for divergent forecasts), W4d (realized operational weight→0), W4e (fixture/golden). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_orchestration.py | 220 +- flextool/engine_polars/_solve_config.py | 78 + flextool/engine_polars/_spinedb_reader.py | 101 + flextool/update_flextool/db_migration.py | 20 +- .../test_w4a_dispatch_realization_guard.py | 255 +- tests/engine_polars/test_w4b_auto_dispatch.py | 499 ++ tests/fixtures/build_w4b_auto_dispatch.py | 84 + tests/fixtures/build_w4b_reserve_dispatch.py | 96 + tests/fixtures/w4b_auto_dispatch.json | 6253 ++++++++++++++++ tests/fixtures/w4b_reserve_dispatch.json | 6367 +++++++++++++++++ 10 files changed, 13870 insertions(+), 103 deletions(-) create mode 100644 tests/engine_polars/test_w4b_auto_dispatch.py create mode 100644 tests/fixtures/build_w4b_auto_dispatch.py create mode 100644 tests/fixtures/build_w4b_reserve_dispatch.py create mode 100644 tests/fixtures/w4b_auto_dispatch.json create mode 100644 tests/fixtures/w4b_reserve_dispatch.json diff --git a/flextool/engine_polars/_orchestration.py b/flextool/engine_polars/_orchestration.py index 4f6e5052b..497eba983 100644 --- a/flextool/engine_polars/_orchestration.py +++ b/flextool/engine_polars/_orchestration.py @@ -1588,36 +1588,212 @@ def _stochastic_invest_raw_guard(solve_cfg, base_name: str) -> None: def _dispatch_realization_raw_guard(solve_cfg, base_name: str) -> None: - """W4 Slice 1 Guard — hard-reject a non-default dispatch_realization_method. - - ``solve.dispatch_realization_method`` is the 2-stage seed of a future - multi-stage market structure. Only ``'within_solve'`` (the schema - default, today's behaviour) is implemented; ``'separate_dispatch'`` — - splitting realization into a dedicated downstream dispatch stage — has - no engine machinery yet (it lands in a later slice) and is HARD-REJECTED - here. + """W4 Guard — validate a non-default ``dispatch_realization_method``. + + ``solve.dispatch_realization_method`` selects how realized operations + are produced. ``'within_solve'`` (the schema default, today's + behaviour) is always allowed. ``'separate_dispatch'`` (W4b) — the + engine automatically registers a deterministic realized-dispatch + SIBLING ``__realized_dispatch`` that takes the full + invest→dispatch handoff + last-wins path — is now ACCEPTED, but ONLY + for a single TOP-LEVEL ``single_solve``. Every other shape (rolling, + a solve that itself ``contains_solves`` children, or a solve nested + inside another's ``contains_solves``) is still rejected: the sibling + machinery is single-solve only. The guard reads the RAW authored value rather than the resolver's output: :meth:`SolveConfig.dispatch_realization_method_for` silently - collapses any unrecognised value (including ``'separate_dispatch'``) to - ``'within_solve'``, so an authoring-time check must inspect the raw dict - and reject anything outside the allowed set ``{"", "within_solve"}``. - That rejects ``'separate_dispatch'`` AND any typo / unknown value. - Blank / absent tolerate-as-default (matches the resolver). No-op when - the solve authors nothing. + collapses any UNRECOGNISED value (a typo) to ``'within_solve'``, so an + authoring-time check must inspect the raw dict. Blank / absent + tolerate-as-default. No-op when the solve authors nothing. + + ``solve_modes`` / ``contains_solves`` are read with ``getattr`` + + ``.get`` defaults so partial unit-test stubs (which may carry only + ``model_solve`` + ``dispatch_realization_method``) don't raise. """ raw = solve_cfg.dispatch_realization_method.get(base_name) if raw is None: return val = str(raw).strip().lower() - if val not in ("", "within_solve"): + if val in ("", "within_solve"): + return + if val != "separate_dispatch": + # Typo / unknown value — unchanged hard-reject. raise FlexToolConfigError( f"Solve '{base_name}' sets dispatch_realization_method='{raw}' — " - f"'separate_dispatch' is not yet implemented (lands in a later " - f"slice); use 'within_solve'." + f"unrecognised value. Supported: 'within_solve' (default) and " + f"'separate_dispatch'." + ) + # separate_dispatch — eligible ONLY for a single top-level single_solve. + solve_modes = getattr(solve_cfg, "solve_modes", {}) or {} + model_solve = getattr(solve_cfg, "model_solve", {}) or {} + contains_solves = getattr(solve_cfg, "contains_solves", {}) or {} + top_level: set = set() + for _solves in model_solve.values(): + top_level.update(_solves) + nested: set = set() + for _children in contains_solves.values(): + if isinstance(_children, (list, tuple, set)): + nested.update(_children) + elif _children is not None: + nested.add(_children) + mode = solve_modes.get(base_name, "single_solve") + reasons: list[str] = [] + if mode != "single_solve": + reasons.append( + f"it runs under solve_mode='{mode}' (separate_dispatch needs a " + f"single_solve)" + ) + if base_name not in top_level: + reasons.append("it is not a top-level solve in model.solves") + if contains_solves.get(base_name): + reasons.append("it has contained solves (contains_solves)") + if base_name in nested: + reasons.append("it is nested inside another solve's contains_solves") + if reasons: + raise FlexToolConfigError( + f"Solve '{base_name}' sets " + f"dispatch_realization_method='separate_dispatch', which is only " + f"supported for a single top-level single_solve, but " + + "; ".join(reasons) + + "." ) +def _spawn_realized_dispatch_siblings( + solve_cfg, cascade_db_reader, logger, +) -> None: + """W4b — register an engine-automatic deterministic realized-dispatch + sibling for every eligible top-level solve that authors + ``dispatch_realization_method = separate_dispatch``. + + Called ONCE from :func:`_drive_cascade` (after ``cascade_db_reader`` + is built, BEFORE ``native_run_model``). For each eligible parent it + performs a DUAL in-memory registration so the sibling takes the FULL + override path — indistinguishable from W8.1's author-declared + dispatch solve: + + 1. ``cascade_db_reader.register_derived_solve(parent, sibling)`` — so + the per-solve override chain treats the sibling as a REAL Spine + solve (``_solve_in_spine`` → ``True``). Without this the loader + falls back to the synthetic ``_`` path, which + silently drops reserves and other entity-level LP structure the + realized dispatch must carry. + 2. ``solve_cfg.spawn_realized_dispatch_sibling(parent, sibling)`` — + insert the sibling RIGHT AFTER its parent in ``model_solve`` (so + it consumes the parent's committed capacity through the normal + invest→dispatch handoff and last-wins hands it the realized + window) and populate its per-solve config as a deterministic, + fixed-capacity, monolithic single_solve. + + *solve_cfg* MUST be ``runner.state.solve`` — the config + ``native_run_model`` actually drives. (The run()-site guard reads the + SEPARATE outer cascade ``state.solve``, where the sibling is never + injected, so it correctly no-ops for the sibling, which authors no + ``dispatch_realization_method`` — no infinite re-spawn.) + + Eligibility was already validated by + :func:`_dispatch_realization_raw_guard` at both wired sites; this + re-selects the same eligible parents from the driving config so the + two stay in lockstep. + + The sibling name is ``f"{parent}__realized_dispatch"`` — the DOUBLE + underscore keeps it NON-period-like so the synthetic resolver's + ``_`` split can never mistake the ``__realized_dispatch`` + tail for a real period anchor (correctness of the synthetic-resolver + no-op relies on the anchor never being a real period). A collision + with an existing solve — in ``model_solve`` values, + ``contains_solves`` values, or the reader's ``solve`` entities — + raises :class:`FlexToolConfigError` naming the colliding owner. + """ + model_solve = getattr(solve_cfg, "model_solve", {}) or {} + top_level: set = set() + for _solves in model_solve.values(): + top_level.update(_solves) + + # Collision sources (name → owner category). + contains_solves = getattr(solve_cfg, "contains_solves", {}) or {} + contained: set = set() + for _children in contains_solves.values(): + if isinstance(_children, (list, tuple, set)): + contained.update(_children) + elif _children is not None: + contained.add(_children) + reader_solves: set = set() + if cascade_db_reader is not None: + try: + _ents = cascade_db_reader.entities("solve") + _name_col = "name" if "name" in _ents.columns else _ents.columns[0] + reader_solves = set(_ents[_name_col].to_list()) + except Exception: # noqa: BLE001 + reader_solves = set() + + eligible = [ + p for p in sorted(top_level) + if solve_cfg.dispatch_realization_method_for(p) == "separate_dispatch" + ] + if eligible and cascade_db_reader is None: + raise FlexToolConfigError( + "dispatch_realization_method='separate_dispatch' requires the " + "DB-backed cascade reader (run via run_chain_from_db with " + "db_url + scenario_name); none is available, so the realized-" + f"dispatch sibling for {eligible!r} cannot be registered." + ) + + for parent in eligible: + # Positive storage-pinning guard (investigation outcome). + # The sibling DROPS ``fix_storage_periods`` — the ONLY solve-level + # storage mechanism it sheds (the node-level + # ``storage_binding_method`` / ``storage_state_*`` are entity + # parameters, preserved via the full override path, so storage + # continuity WITHIN the monolithic single_solve is intact). Any + # authored ``fix_storage_periods`` would therefore be silently + # re-floated by the sibling — giving a DIFFERENT storage trajectory + # than the parent pinned. Reject CONSERVATIVELY on ANY non-empty + # ``fix_storage_periods`` (independent of realized-period count): + # even a single pinned boundary is dropped, and gating on period + # count would UNDER-fire (a single realized period with a 2-period + # fix_storage authored still drops the pin). The combo is + # brand-new; storage pinning in the realized-dispatch solve is a + # later slice. No ``fix_storage_periods`` → fine: the monolithic + # solve links storage via its own balance constraints exactly as + # the parent would. + _fix_storage = getattr(solve_cfg, "fix_storage_periods", {}) or {} + if _fix_storage.get(parent): + raise FlexToolConfigError( + f"Solve '{parent}' sets " + f"dispatch_realization_method='separate_dispatch' but also " + f"authors fix_storage_periods: separate_dispatch is not yet " + f"supported for a solve that authors fix_storage_periods " + f"(storage pinning in the realized-dispatch solve is a later " + f"slice; the auto realized-dispatch sibling would re-float " + f"the pinned storage boundaries). Clear fix_storage_periods." + ) + sibling = f"{parent}__realized_dispatch" + if sibling in top_level or sibling in contained: + raise FlexToolConfigError( + f"Cannot auto-register realized-dispatch sibling " + f"'{sibling}' for solve '{parent}': a solve named " + f"'{sibling}' already exists in model.solves / " + f"contains_solves." + ) + if sibling in reader_solves: + raise FlexToolConfigError( + f"Cannot auto-register realized-dispatch sibling " + f"'{sibling}' for solve '{parent}': a 'solve' entity named " + f"'{sibling}' already exists in the database." + ) + cascade_db_reader.register_derived_solve(parent, sibling) + solve_cfg.spawn_realized_dispatch_sibling(parent, sibling) + reader_solves.add(sibling) + top_level.add(sibling) + if logger is not None: + logger.info( + "W4b: registered engine-automatic realized-dispatch sibling " + "'%s' for separate_dispatch solve '%s'", sibling, parent, + ) + + def _validate_model_solve(state: RunnerState) -> list[str]: """Validate ``state.solve.model_solve`` and return the solve list. @@ -2016,6 +2192,16 @@ def _drive_cascade( # the output reader falls back to its reconstruction path. pass + # W4b — register the engine-automatic realized-dispatch sibling for + # every eligible ``separate_dispatch`` top-level solve, BEFORE + # ``native_run_model`` expands the solve tree. Dual in-memory + # registration onto the DRIVING config (``runner.state.solve``) and + # the cascade reader so the sibling takes the full override path. + # No-op (and byte-parity) when no solve authors separate_dispatch. + _spawn_realized_dispatch_siblings( + runner.state.solve, cascade_db_reader, _drive_logger, + ) + class _PolarHighCascadeSolver(SolverRunner): def __init__(self, runner_state): super().__init__(runner_state) diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 3f8ed41ef..0e8570830 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -21,6 +21,7 @@ """ from __future__ import annotations +import copy import logging from collections import defaultdict from dataclasses import dataclass, field @@ -916,6 +917,83 @@ def duplicate_solve( solves.append(new_name) self.model_solve[model] = solves + def spawn_realized_dispatch_sibling( + self, parent: str, sibling: str, + ) -> None: + """W4b — positionally insert *sibling* right after *parent* in + ``model_solve`` and populate the per-solve config dicts that make + it a deterministic, fixed-capacity, monolithic dispatch over the + parent's REALIZED window. + + The sibling is the engine-automatic counterpart of W8.1's + author-declared ``*_realized_dispatch`` solve: it consumes the + parent's committed capacity through the normal invest→dispatch + handoff (hence the insert IMMEDIATELY after the parent, not an + append) and last-wins hands it the realized window it then owns. + + COPY from the parent (timeline / period scaffolding): + ``timesets_used_by_solves``, ``solve_period_years_represented``, + ``periods_available``, ``delay_durations``, ``hole_multipliers``, + ``rolling_times`` (empty for a single_solve). + + SET: ``solve_modes[sibling] = "single_solve"`` and + ``realized_periods[sibling] = realized_periods[parent]`` (the + realized window the sibling OWNS after last-wins). + + DROP (no key created — the ``defaultdict(list)`` / plain-dict + defaults then make the sibling investment-free, deterministic and + monolithic): ``invest_periods``, ``realized_invest_periods``, + ``fix_storage_periods``, ``stochastic_branches``, + ``non_anticipativity_periods``, + ``non_anticipativity_invest_periods``, ``decomposition``, + ``benders_max_iter`` / ``benders_tolerance`` / + ``benders_in_out_weight``, ``stochastic_invest_method``, + ``contains_solves``, and ``dispatch_realization_method`` (absent → + :meth:`dispatch_realization_method_for` yields ``within_solve`` → + the run()-site guard no-ops → no re-spawn). + + NOT copied — ``scaling`` / ``solver_configs`` / ``use_row_scaling`` + / ``scale_the_objective`` / ``user_bound_scale``: the cascade + ``run()`` closure in ``_orchestration._drive_cascade`` resolves + those from the SEPARATE cascade ``state.solve`` (NOT this + ``runner.state.solve``, which is the config this method mutates), + so copies here would be dead. The sibling therefore runs under + the DEFAULT solver / scaling / decomposition config — correct for + a monolithic deterministic dispatch. + + Note on multi-period storage: dropping ``fix_storage_periods`` is + correct even when the realized window spans MULTIPLE periods. + ``fix_storage_periods`` pins storage at period boundaries for + ROLLING / multi-solve handoff continuity; a single monolithic + ``single_solve`` instead links storage across its periods through + the ordinary storage-balance constraints and respects the + node-level ``storage_binding_method`` (an entity parameter, read + unchanged via the full override path). So the sibling's storage + trajectory over its realized periods is a correct free + deterministic optimisation, not a re-floated gap. + """ + # Positional insert right after the parent (NOT append + remove). + for _model, solves in self.model_solve.items(): + if parent in solves and sibling not in solves: + solves.insert(solves.index(parent) + 1, sibling) + # COPY the timeline / period scaffolding. + for dict_name in ( + "timesets_used_by_solves", + "solve_period_years_represented", + "periods_available", + "delay_durations", + "hole_multipliers", + "rolling_times", + ): + d = getattr(self, dict_name, None) + if d is not None and parent in d: + d[sibling] = copy.deepcopy(d[parent]) + # SET the two defining knobs. + self.solve_modes[sibling] = "single_solve" + self.realized_periods[sibling] = copy.deepcopy( + self.realized_periods.get(parent, []) + ) + # ------------------------------------------------------------------ # v60/v62 per-solve decomposition accessors # ------------------------------------------------------------------ diff --git a/flextool/engine_polars/_spinedb_reader.py b/flextool/engine_polars/_spinedb_reader.py index d31d4578c..8917f592e 100644 --- a/flextool/engine_polars/_spinedb_reader.py +++ b/flextool/engine_polars/_spinedb_reader.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +import copy import logging from collections import defaultdict from typing import Any @@ -292,6 +293,106 @@ def scenario(self) -> str: def __repr__(self) -> str: return f"SpineDbReader(db_url={self._db_url!r}, scenario={self._scenario!r})" + # ------------------------------------------------------------------ + # W4b — engine-automatic realized-dispatch sibling registration + + #: Solve-class parameters cloned verbatim from the parent onto a + #: registered ``*__realized_dispatch`` sibling. Deliberately an + #: ALLOW-LIST (not copy-all-minus-drop): it carries exactly the four + #: parameters that define a deterministic dispatch over the realized + #: window — the SAME set an author hand-declares for a dispatch solve + #: (cf. W8.1's ``stoch_rd_realized_dispatch``, which authors only + #: these four). ``invest_periods`` / ``realized_invest_periods`` / + #: ``stochastic_branches`` / ``fix_storage_periods`` / decomposition / + #: benders knobs are intentionally NOT copied so the sibling cannot + #: invest, branch, or decompose — it is a pure fixed-capacity dispatch. + _REALIZED_DISPATCH_SIBLING_PARAMS = ( + "period_timeset", + "realized_periods", + "years_represented", + "solve_mode", + ) + + def register_derived_solve( + self, parent_name: str, sibling_name: str, + ) -> None: + """In-memory register a derived ``solve`` entity *sibling_name* + cloned from *parent_name* so the per-solve override chain treats + it as a REAL Spine solve (:func:`_solve_in_spine` → ``True``), not + a synthetic ``_`` sub-solve. + + Taking the full override path is what keeps the sibling's LP + complete: the synthetic fallback returns ``None`` for every + solve-keyed helper, so entity-level structure the realized + dispatch must carry (reserves, storage binding, …) would be + silently dropped. + + Mechanism — the THREE caches every ``solve`` consumer of this + reader touches: + + * ``_entities_by_class[solve_cls]`` — append ``(new_eid, + sibling_name, None)`` so :meth:`entities` (``"solve"``) lists + it; + * ``_entity_by_id[new_eid]`` — map the FRESH id back to + ``(solve_cls, sibling_name)`` so value-join / unroll paths + resolve the sibling's OWN name. (Reusing the parent eid for + the sibling's rows would map them back to the parent name, so + ``_solve_periods(sibling)`` reads empty and the loader silently + reverts to the synthetic path — critique fix #1.) + * ``_param_rows[(solve_cls, pdef_id)]`` — for each parameter in + :data:`_REALIZED_DISPATCH_SIBLING_PARAMS`, append ``(new_eid, + )`` keyed on the FRESH + id. + + A fresh deterministic id (``max(_entity_by_id) + 1``) is minted. + Idempotent: a second call for an already-registered sibling is a + no-op. Raises :class:`FlexToolConfigError` when the ``solve`` + class or *parent_name* is absent. + + There is no entity-by-name map or ``model__solve`` relationship + cache in this reader (verified: the only ``solve`` consumers are + :meth:`entities` via ``_solve_in_spine`` / the synthetic resolver, + plus the ``_param_rows`` reads), so these three caches suffice. + """ + solve_cls = self._class_name_to_id.get("solve") + if solve_cls is None: + raise FlexToolConfigError( + "register_derived_solve: no 'solve' entity class in the DB." + ) + ents = self._entities_by_class.get(solve_cls, []) + parent_eid: int | None = None + for eid, name, _elems in ents: + if name == sibling_name: + return # already registered — idempotent no-op + if name == parent_name: + parent_eid = eid + if parent_eid is None: + raise FlexToolConfigError( + f"register_derived_solve: parent solve {parent_name!r} " + f"not found in the 'solve' entity class." + ) + # Fresh, deterministic entity id (never collides with an existing). + new_eid = max(self._entity_by_id) + 1 + self._entities_by_class[solve_cls].append( + (new_eid, sibling_name, None) + ) + self._entity_by_id[new_eid] = (solve_cls, sibling_name) + # Clone the dispatch-defining parameter rows under the FRESH id. + for pname in self._REALIZED_DISPATCH_SIBLING_PARAMS: + pdef = self._pdef_by_class_name.get((solve_cls, pname)) + if pdef is None: + continue + key = (solve_cls, pdef["id"]) + rows = self._param_rows.get(key) + if not rows: + continue + for eid, val in rows: + if eid == parent_eid: + self._param_rows[key].append( + (new_eid, copy.deepcopy(val)) + ) + break + # ------------------------------------------------------------------ # Entity columns: ``[name]`` for 0-dim classes; one column per dim # (named after the dim class) for n-dim relationships. Repeated diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 51e4976d7..1c0f48ab4 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -4229,12 +4229,16 @@ def _migrate_v75_dispatch_realization_method(db) -> None: revealed branch), the first step toward a multi-stage sequential-market structure. - W4 Slice 1 ships only the schema + the hard-reject guard: flag-on - (``'separate_dispatch'``) is HARD-REJECTED until the separate-dispatch - machinery lands (slices W4b/c/d — building the downstream dispatch - stage). Bound to a new 'dispatch_realization_methods' value list; - grouped under 'solve_advanced' (created if the DB lacks it). This is a - structural clone of the v70 ``stochastic_invest_method`` line. + W4 Slice 1 (W4a) shipped the schema; W4b now IMPLEMENTS the engine + side: ``'separate_dispatch'`` is ACCEPTED for a single top-level + ``single_solve``, for which the engine automatically registers a + deterministic realized-dispatch sibling ``__realized_dispatch`` + (full invest→dispatch handoff + last-wins). Other shapes (rolling, + contains_solves children, nested) and solves that author + fix_storage_periods remain rejected (later slices). Bound to a new + 'dispatch_realization_methods' value list; grouped under + 'solve_advanced' (created if the DB lacks it). This is a structural + clone of the v70 ``stochastic_invest_method`` line. """ add_value_list_manual(db, [ ["dispatch_realization_methods", "within_solve"], @@ -4290,7 +4294,9 @@ def _migrate_v75_dispatch_realization_method(db) -> None: "v75: added solve.dispatch_realization_method " "(dispatch_realization_methods: within_solve/separate_dispatch, " "default within_solve); 2-stage seed of a future multi-stage market " - "structure. 'separate_dispatch' hard-rejected until a later slice." + "structure. 'separate_dispatch' accepted (W4b) for a single " + "top-level single_solve — engine auto-spawns the " + "__realized_dispatch sibling." ) diff --git a/tests/engine_polars/test_w4a_dispatch_realization_guard.py b/tests/engine_polars/test_w4a_dispatch_realization_guard.py index 0a073a84b..a8dec30e1 100644 --- a/tests/engine_polars/test_w4a_dispatch_realization_guard.py +++ b/tests/engine_polars/test_w4a_dispatch_realization_guard.py @@ -1,28 +1,24 @@ -"""W4 Slice 1 (W4a) — dispatch_realization_method hard-reject guard. +"""W4 — ``dispatch_realization_method`` guard (W4a schema + W4b narrowing). -``solve.dispatch_realization_method`` is the 2-stage seed of a future -multi-stage market structure. Only ``'within_solve'`` (the schema -default, today's behaviour) is implemented; ``'separate_dispatch'`` — a -dedicated downstream dispatch stage — has no engine machinery yet and is -HARD-REJECTED until a later slice. +``solve.dispatch_realization_method`` selects how realized operations are +produced. ``'within_solve'`` (the schema default, today's behaviour) is +always allowed. ``'separate_dispatch'`` is now ACCEPTED — but ONLY for a +single TOP-LEVEL ``single_solve`` (W4b), for which the engine +automatically registers a deterministic realized-dispatch sibling +``__realized_dispatch``. Every other shape is rejected: + + * a rolling (``solve_mode != single_solve``) solve; + * a solve that itself ``contains_solves`` children; + * a solve nested inside another solve's ``contains_solves``; + * any typo / unrecognised value (unchanged hard-reject). The guard (:func:`_dispatch_realization_raw_guard`) reads the RAW authored -value and its allowed set is EXPLICITLY ``{"", "within_solve"}`` — it -raises :class:`FlexToolConfigError` for ``'separate_dispatch'`` AND for any -unrecognised value. The normalizing accessor +value because the normalizing accessor (:meth:`SolveConfig.dispatch_realization_method_for`) would silently -collapse those to ``'within_solve'``, which is why the guard inspects the -raw dict. - -The guard is wired at BOTH v70-style call sites: - -* ``_validate_model_solve`` — a fail-fast pass over the model's top-level - solves. -* the authoritative ``run(complete_solve_name)`` site, which strips the - ``_roll_\\d+$`` suffix and fires for every solve that RUNS (including - each roll of a rolling solve and each contained/chained sub-solve). The - roll-suffix-strip composition is exercised here directly (mirrors the - v70 ``test_g1_rolling_suffix_strip_composition``). +collapse a typo to ``'within_solve'``. It is wired at BOTH v70-style call +sites: ``_validate_model_solve`` (fail-fast over top-level solves) and the +authoritative ``run(complete_solve_name)`` site (which strips the +``_roll_\\d+$`` suffix and fires for every solve that RUNS). """ from __future__ import annotations @@ -36,18 +32,27 @@ # --------------------------------------------------------------------------- -# A bare SolveConfig carrying only the two fields the guard/accessor read — -# ``model_solve`` and ``dispatch_realization_method``. +# A bare SolveConfig carrying only the fields the guard/accessor read. # --------------------------------------------------------------------------- -def _solve_config_with_method(model_solve, method_map) -> SolveConfig: +def _solve_config_with_method( + model_solve, + method_map, + *, + solve_modes=None, + contains_solves=None, +) -> SolveConfig: sc = object.__new__(SolveConfig) sc.model_solve = model_solve sc.dispatch_realization_method = method_map + # The W4b eligibility predicate reads these (via getattr/.get defaults, + # so partial stubs are tolerated); set them explicitly where a test + # exercises the non-single_solve / nested shapes. + sc.solve_modes = solve_modes if solve_modes is not None else {} + sc.contains_solves = contains_solves if contains_solves is not None else {} # ``_validate_model_solve`` also runs the sibling stochastic_invest - # fail-fast pass, which reads this dict; keep it empty (all-default) so - # that guard is inert and only the dispatch guard is under test. + # fail-fast pass; keep it inert. sc.stochastic_invest_method = {} return sc @@ -69,40 +74,38 @@ def test_resolver_normalisation(): }, ) assert sc.dispatch_realization_method_for("s") == "within_solve" - # case-fold assert sc.dispatch_realization_method_for("loud") == "within_solve" - # the non-default listed value normalises cleanly (resolver recognises - # it; the GUARD is what rejects it — see below) - assert ( - sc.dispatch_realization_method_for("sep") == "separate_dispatch" - ) - # blank / typo / absent collapse to the default + assert sc.dispatch_realization_method_for("sep") == "separate_dispatch" assert sc.dispatch_realization_method_for("blank") == "within_solve" assert sc.dispatch_realization_method_for("junk") == "within_solve" assert sc.dispatch_realization_method_for("absent") == "within_solve" # =========================================================================== -# Guard — hard-reject separate_dispatch + any unrecognised value +# Guard — accept an eligible single top-level separate_dispatch solve # =========================================================================== -def test_raw_guard_rejects_separate_dispatch_and_typos(): - """The guard inspects the RAW authored value (the resolver would - silently collapse a typo to 'within_solve' and happily pass - 'separate_dispatch' to downstream machinery that does not exist yet), - so 'separate_dispatch' AND any unrecognised value raise — while - 'within_solve' / blank / absent do not.""" +def test_raw_guard_accepts_eligible_separate_dispatch(): + """A single top-level ``single_solve`` authoring ``separate_dispatch`` + passes the guard (the engine will auto-spawn its realized-dispatch + sibling). ``within_solve`` / blank / absent also pass.""" from flextool.engine_polars._orchestration import ( _dispatch_realization_raw_guard, ) - for bad in ("separate_dispatch", "SEPARATE_DISPATCH", "wihtin_solve", - "expected_value"): - sc = _solve_config_with_method({"m": ["s"]}, {"s": bad}) - with pytest.raises(FlexToolConfigError) as exc: - _dispatch_realization_raw_guard(sc, "s") - assert bad in str(exc.value) + sc = _solve_config_with_method( + {"m": ["s"]}, {"s": "separate_dispatch"}, + solve_modes={"s": "single_solve"}, + ) + _dispatch_realization_raw_guard(sc, "s") # no raise + + # Case-folded value also accepted. + sc = _solve_config_with_method( + {"m": ["s"]}, {"s": "SEPARATE_DISPATCH"}, + solve_modes={"s": "single_solve"}, + ) + _dispatch_realization_raw_guard(sc, "s") # no raise # Accepted / tolerated raw values → no raise. for ok in ("within_solve", "WITHIN_SOLVE", "", " within_solve "): @@ -113,81 +116,160 @@ def test_raw_guard_rejects_separate_dispatch_and_typos(): _solve_config_with_method({"m": ["s"]}, {}), "s") -def test_separate_dispatch_message_names_solve_and_within_solve(): - """The reject message names the solve and points at 'within_solve'.""" +def test_raw_guard_rejects_typos(): + """An unrecognised value (typo) is still a hard-reject — the resolver + would silently collapse it to 'within_solve'.""" from flextool.engine_polars._orchestration import ( _dispatch_realization_raw_guard, ) - sc = _solve_config_with_method({"m": ["my_solve"]}, - {"my_solve": "separate_dispatch"}) + for bad in ("wihtin_solve", "expected_value", "sep_dispatch"): + sc = _solve_config_with_method( + {"m": ["s"]}, {"s": bad}, solve_modes={"s": "single_solve"}, + ) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "s") + assert bad in str(exc.value) + + +# =========================================================================== +# Guard narrowing — separate_dispatch rejected for non-single-top-level +# =========================================================================== + + +def test_raw_guard_rejects_rolling_separate_dispatch(): + """A rolling (``solve_mode != single_solve``) separate_dispatch solve is + rejected — the sibling machinery is single_solve only.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method( + {"m": ["s"]}, {"s": "separate_dispatch"}, + solve_modes={"s": "rolling_window"}, + ) with pytest.raises(FlexToolConfigError) as exc: - _dispatch_realization_raw_guard(sc, "my_solve") + _dispatch_realization_raw_guard(sc, "s") + msg = str(exc.value) + assert "single_solve" in msg and "rolling_window" in msg + + +def test_raw_guard_rejects_separate_dispatch_with_children(): + """A separate_dispatch solve that itself ``contains_solves`` children is + rejected.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method( + {"m": ["parent"]}, {"parent": "separate_dispatch"}, + solve_modes={"parent": "single_solve"}, + contains_solves={"parent": ["child_a", "child_b"]}, + ) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "parent") + assert "contained solves" in str(exc.value) + + +def test_raw_guard_rejects_nested_separate_dispatch(): + """A separate_dispatch solve nested inside another's ``contains_solves`` + is rejected (it is not a top-level solve).""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method( + {"m": ["outer"]}, {"nested": "separate_dispatch"}, + solve_modes={"nested": "single_solve"}, + contains_solves={"outer": ["nested"]}, + ) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "nested") msg = str(exc.value) - assert "my_solve" in msg - assert "not yet implemented" in msg - assert "within_solve" in msg + assert "top-level" in msg or "nested" in msg + + +def test_separate_dispatch_reject_message_names_solve(): + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method( + {"m": ["my_solve"]}, {"my_solve": "separate_dispatch"}, + solve_modes={"my_solve": "rolling_window"}, + ) + with pytest.raises(FlexToolConfigError) as exc: + _dispatch_realization_raw_guard(sc, "my_solve") + assert "my_solve" in str(exc.value) + + +# =========================================================================== +# Call site 1: _validate_model_solve fail-fast pass +# =========================================================================== -# --- Call site 1: _validate_model_solve fail-fast pass ------------------ +def test_validate_model_solve_accepts_eligible_separate_dispatch(): + """The fail-fast pass ACCEPTS an eligible single top-level + separate_dispatch solve (alongside other top-level single_solves).""" + from flextool.engine_polars._orchestration import _validate_model_solve + + sc = _solve_config_with_method( + {"m": ["s_ok", "s_sep"]}, {"s_sep": "separate_dispatch"}, + solve_modes={"s_ok": "single_solve", "s_sep": "single_solve"}, + ) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + assert _validate_model_solve(state) == ["s_ok", "s_sep"] -def test_validate_model_solve_rejects_separate_dispatch(): - """The fail-fast pass over the model's top-level solves raises on - 'separate_dispatch' before any solve runs.""" +def test_validate_model_solve_rejects_rolling_separate_dispatch(): from flextool.engine_polars._orchestration import _validate_model_solve - sc = _solve_config_with_method({"m": ["s_ok", "s_sep"]}, - {"s_sep": "separate_dispatch"}) + sc = _solve_config_with_method( + {"m": ["s_sep"]}, {"s_sep": "separate_dispatch"}, + solve_modes={"s_sep": "rolling_window"}, + ) state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) with pytest.raises(FlexToolConfigError): _validate_model_solve(state) def test_validate_model_solve_passes_within_solve_and_absent(): - """'within_solve' and absent (all-default) pass and return the solve - list unchanged.""" from flextool.engine_polars._orchestration import _validate_model_solve - # Explicit within_solve on one solve, the other unset. sc = _solve_config_with_method({"m": ["s1", "s2"]}, {"s1": "within_solve"}) state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) assert _validate_model_solve(state) == ["s1", "s2"] - # All default (absent). sc2 = _solve_config_with_method({"m": ["s1", "s2"]}, {}) state2 = dataclasses.make_dataclass("S", ["solve"])(solve=sc2) assert _validate_model_solve(state2) == ["s1", "s2"] -# --- Call site 2: authoritative run() site (roll-suffix strip) ---------- +# =========================================================================== +# Call site 2: authoritative run() site (roll-suffix strip) +# =========================================================================== def test_run_site_rejects_rolling_child_of_separate_dispatch(): - """The authoritative ``run()`` guard strips the ``_roll_N`` suffix - before the raw-dict lookup, so a rolling / contained child of a - separate_dispatch solve is rejected — this mirrors the exact code the - run() site executes (``re.sub`` strip -> ``_dispatch_realization_raw_guard``), - so a contained/chained/rolling sub-solve cannot silently normalize to - 'within_solve'.""" + """A rolling child of a separate_dispatch solve reaches run() as + ``_roll_``; the guard strips the suffix, then rejects because + the base runs under ``solve_mode=rolling_window``.""" from flextool.engine_polars._orchestration import ( _dispatch_realization_raw_guard, ) - sc = _solve_config_with_method({"m": ["my_solve"]}, - {"my_solve": "separate_dispatch"}) - # Rolling child name as it reaches run(): "_roll_". - complete_solve_name = "my_solve_roll_2" - guard_base = re.sub(r"_roll_\d+$", "", complete_solve_name) + sc = _solve_config_with_method( + {"m": ["my_solve"]}, {"my_solve": "separate_dispatch"}, + solve_modes={"my_solve": "rolling_window"}, + ) + guard_base = re.sub(r"_roll_\d+$", "", "my_solve_roll_2") assert guard_base == "my_solve" with pytest.raises(FlexToolConfigError): _dispatch_realization_raw_guard(sc, guard_base) def test_run_site_passes_within_solve_rolling_child(): - """A rolling child of a within_solve (or unset) solve passes the - run()-site guard.""" from flextool.engine_polars._orchestration import ( _dispatch_realization_raw_guard, ) @@ -196,6 +278,21 @@ def test_run_site_passes_within_solve_rolling_child(): {"my_solve": "within_solve"}) guard_base = re.sub(r"_roll_\d+$", "", "my_solve_roll_7") _dispatch_realization_raw_guard(sc, guard_base) # no raise - # Unset base → no raise. sc2 = _solve_config_with_method({"m": ["my_solve"]}, {}) _dispatch_realization_raw_guard(sc2, guard_base) # no raise + + +def test_run_site_sibling_name_no_ops(): + """The auto-spawned sibling authors no dispatch_realization_method (it + lives only in ``runner.state.solve``, not the guard's config), so the + run()-site guard no-ops for it — no infinite re-spawn.""" + from flextool.engine_polars._orchestration import ( + _dispatch_realization_raw_guard, + ) + + sc = _solve_config_with_method( + {"m": ["my_solve"]}, {"my_solve": "separate_dispatch"}, + solve_modes={"my_solve": "single_solve"}, + ) + # sibling absent from the dispatch dict → no raise. + _dispatch_realization_raw_guard(sc, "my_solve__realized_dispatch") diff --git a/tests/engine_polars/test_w4b_auto_dispatch.py b/tests/engine_polars/test_w4b_auto_dispatch.py new file mode 100644 index 000000000..17816c023 --- /dev/null +++ b/tests/engine_polars/test_w4b_auto_dispatch.py @@ -0,0 +1,499 @@ +"""W4b — engine-automatic realized-dispatch sibling for a single +top-level ``separate_dispatch`` solve. + +W8.1 (``test_w8_realized_dispatch.py``) proved that realized operations +can come from a SEPARATE deterministic dispatch solve whose investments +are fixed from a stochastic solve — using an AUTHOR-DECLARED second solve. +W4b removes the author-declared solve: a single top-level solve that sets +``dispatch_realization_method = separate_dispatch`` causes the engine to +AUTOMATICALLY register a deterministic realized-dispatch SIBLING +``__realized_dispatch`` that takes the full override path +(invest→dispatch handoff fixes committed capacity; realized last-wins +empties the parent's realized window; the sibling owns the reported +realized operations). The auto-spawned sibling must be INDISTINGUISHABLE +from W8.1's hand-declared dispatch solve. + +This module pins: + + * the four W8.1 oracles, now against the AUTO-spawned + ``stoch_rd__realized_dispatch`` step + (``w4b_auto_dispatch`` fixture — W8.1 collapsed to one solve + + ``dispatch_realization_method=separate_dispatch``); + * a RESERVE full-path proof — a ``separate_dispatch`` solve carrying an + up-reserve requirement; the sibling's LP must contain a reserve + variable (the synthetic ``_`` fallback would silently + drop the reserve subsystem), proving the sibling takes the real DB + override path; + * the two in-memory registration primitives + (:meth:`SpineDbReader.register_derived_solve`, + :meth:`SolveConfig.spawn_realized_dispatch_sibling`). +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import polars as pl +import pytest + +FLEXTOOL_ROOT = Path(__file__).resolve().parents[2] +AUTO_FIXTURE = FLEXTOOL_ROOT / "tests" / "fixtures" / "w4b_auto_dispatch.json" +RESERVE_FIXTURE = ( + FLEXTOOL_ROOT / "tests" / "fixtures" / "w4b_reserve_dispatch.json" +) + +SCENARIO = "w8_realized_dispatch" +PARENT = "stoch_rd" +SIBLING = "stoch_rd__realized_dispatch" +REALIZED_STEPS = {"t0001", "t0002", "t0003"} + +# Hand-calc constants (identical to W8.1 — the sibling reproduces them). +COMMITTED_PEAKER_MW = 60.0 +HOURS_PER_YEAR = 8760.0 +PEAKER_VAR_COST = 10.0 +DISPATCH_OBJECTIVE = COMMITTED_PEAKER_MW * PEAKER_VAR_COST * HOURS_PER_YEAR +RESERVE_MW = 10.0 + + +def _build_db(fixture_json: Path, tmp_path: Path) -> str: + """Build-from-JSON + migrate (CLAUDE.md invariant 3; never read a + checked-in .sqlite).""" + if not fixture_json.exists(): + pytest.skip(f"fixture JSON not present: {fixture_json}") + tests_dir = FLEXTOOL_ROOT / "tests" + if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) + from db_utils import json_to_db # noqa: E402 + from flextool.update_flextool.db_migration import migrate_database + + db_path = tmp_path / f"{fixture_json.stem}.sqlite" + url = json_to_db(fixture_json, db_path) + migrate_database(url) + return url + + +def _run_chain(fixture_json: Path, tmp_path: Path): + """Run the single-solve chain once under ``FLEXTOOL_AUTOSCALE_STRICT=1`` + with ``warm=False`` + ``keep_solutions=True`` and return + ``(steps, work_folder)``.""" + from flextool.engine_polars import run_chain_from_db + + url = _build_db(fixture_json, tmp_path) + work_folder = tmp_path / "work" + work_folder.mkdir() + + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + steps = run_chain_from_db( + url, SCENARIO, work_folder=work_folder, + warm=False, keep_solutions=True, + ) + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + assert steps, "run_chain_from_db produced no orchestration steps" + return steps, work_folder + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("w4b_auto_dispatch") + return _run_chain(AUTO_FIXTURE, tmp_path) + + +@pytest.fixture(scope="module") +def reserve_chain(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("w4b_reserve_dispatch") + return _run_chain(RESERVE_FIXTURE, tmp_path) + + +def _step(steps, needle): + key = next((k for k in steps if k == needle), None) or next( + (k for k in steps if needle in k), None + ) + assert key is not None, f"no {needle!r} step in {list(steps)}" + return steps[key] + + +def _as_frame(param): + if param is None: + return None + return param.frame if hasattr(param, "frame") else param + + +# =========================================================================== +# The sibling is auto-spawned at all +# =========================================================================== + + +@pytest.mark.solver +def test_sibling_is_auto_registered(chain) -> None: + """The engine spawned ``__realized_dispatch`` even though the + fixture declares only ONE solve — no author-declared dispatch solve.""" + steps, _wf = chain + assert PARENT in steps, f"parent solve missing: {list(steps)}" + assert SIBLING in steps, ( + f"engine did not auto-register the realized-dispatch sibling " + f"{SIBLING!r}: {list(steps)}" + ) + + +# =========================================================================== +# (a) both solves optimal +# =========================================================================== + + +@pytest.mark.solver +def test_both_solves_optimal(chain) -> None: + steps, _wf = chain + stoch = _step(steps, PARENT) + dispatch = _step(steps, SIBLING) + assert stoch.solution is not None and stoch.optimal + assert dispatch.solution is not None and dispatch.optimal + + +# =========================================================================== +# (b) sibling builds NO v_invest; capacity fixed at the committed 60 MW +# =========================================================================== + + +@pytest.mark.solver +def test_sibling_capacity_is_fixed_from_invest(chain) -> None: + steps, _wf = chain + stoch = _step(steps, PARENT) + dispatch = _step(steps, SIBLING) + + assert stoch.flex_data is not None + assert stoch.flex_data.pd_invest_set is not None + pcol = stoch.flex_data.pd_invest_set.columns[0] + assert "peaker" in set(stoch.flex_data.pd_invest_set[pcol].to_list()) + + assert stoch.handoff is not None and not stoch.handoff.is_empty() + ri = stoch.handoff.realized_invest + assert ri is not None and ri.height > 0 + ent_col, val_col = ri.columns[0], ri.columns[-1] + committed = {r[ent_col]: float(r[val_col]) for r in ri.iter_rows(named=True)} + assert committed.get("peaker") == pytest.approx(COMMITTED_PEAKER_MW) + + # sibling builds no invest set / v_invest ... + assert dispatch.flex_data is not None + assert dispatch.flex_data.pd_invest_set is None + disp_vars = set(getattr(dispatch.solution, "_vars", {}).keys()) + assert not any("v_invest" in v for v in disp_vars), ( + f"sibling built v_invest variables {sorted(disp_vars)}" + ) + + # ... yet carries the committed capacity as fixed existing capacity. + pei = _as_frame(dispatch.flex_data.p_entity_invested) + assert pei is not None + pei_ent, pei_val = pei.columns[0], pei.columns[-1] + dispatch_cap = {r[pei_ent]: float(r[pei_val]) for r in pei.iter_rows(named=True)} + assert dispatch_cap.get("peaker") == pytest.approx(COMMITTED_PEAKER_MW) + assert dispatch_cap["peaker"] == pytest.approx(committed["peaker"]) + + +# =========================================================================== +# (c) last-wins: sibling owns the realized window +# =========================================================================== + + +@pytest.mark.solver +def test_realized_window_owned_by_sibling(chain) -> None: + steps, _wf = chain + stoch = _step(steps, PARENT) + dispatch = _step(steps, SIBLING) + + stoch_rd = stoch.flex_data.realized_dispatch + if stoch_rd is not None: + remaining = stoch_rd.filter(pl.col("period") == "p2035") + assert remaining.height == 0, ( + "parent still owns realized p2035 cells — last-wins did not hand " + f"them to the auto sibling:\n{stoch_rd}" + ) + + disp_rd = dispatch.flex_data.realized_dispatch + assert disp_rd is not None and disp_rd.height > 0 + owned = disp_rd.filter(pl.col("period") == "p2035") + assert set(owned["step"].to_list()) == REALIZED_STEPS + + +# =========================================================================== +# (d) hand-calc of the sibling's objective +# =========================================================================== + + +@pytest.mark.solver +def test_sibling_objective_hand_calc(chain) -> None: + steps, wf = chain + dispatch = _step(steps, SIBLING) + + obj = float( + pl.read_parquet(wf / "output_raw" / f"v_obj__{SIBLING}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(DISPATCH_OBJECTIVE, rel=1e-9) + + flows = dispatch.solution.value("v_flow") + peaker = flows.filter(pl.col("source") == "peaker") + assert peaker.height == 3 + assert all(v == pytest.approx(60.0) for v in peaker["value"].to_list()) + wind = flows.filter(pl.col("source") == "wind") + assert wind.height == 3 + assert all(v == pytest.approx(0.40) for v in wind["value"].to_list()) + + +# =========================================================================== +# RESERVE full-path proof — the synthetic path would drop the reserve +# =========================================================================== + + +@pytest.mark.solver +def test_reserve_present_in_sibling_lp(reserve_chain) -> None: + """A ``separate_dispatch`` solve carrying an up-reserve requirement: + the auto sibling's LP must CONTAIN the reserve subsystem (``v_reserve`` + and the reserve relationship frames), and the peaker must actually hold + the 10 MW/step reserve on its FIXED capacity. + + This is a non-vacuous LP-completeness + provision check (it would catch + a regression that dropped reserves from the sibling), but note it is NOT + itself the full-vs-synthetic discriminator: the reserve relationship + frames (``process_reserve_upDown_node_active`` / + ``reserve_upDown_group``) are built unconditionally from the ENTITY + source, not keyed on the solve name, so they survive either path. The + GENUINE full-vs-synthetic discriminator lives in the oracle tests + above: on the synthetic ``_`` path + ``_solve_periods(sibling, "realized_periods")`` returns ``None``, so + last-wins would never empty the parent's p2035 window and the realized- + ownership + exact-objective assertions would fail. Here we additionally + confirm the reserve subsystem is live on the sibling's fixed-capacity + dispatch.""" + steps, _wf = reserve_chain + stoch = _step(steps, PARENT) + dispatch = _step(steps, SIBLING) + assert stoch.optimal and dispatch.optimal + + disp_vars = set(getattr(dispatch.solution, "_vars", {}).keys()) + assert "v_reserve" in disp_vars, ( + "sibling built NO v_reserve — the reserve subsystem was dropped, so " + f"the sibling fell back to the synthetic path. vars={sorted(disp_vars)}" + ) + + # The reserve relationship frames survived onto the sibling's FlexData. + assert dispatch.flex_data.reserve_upDown_group is not None + assert dispatch.flex_data.reserve_upDown_group.height > 0 + assert dispatch.flex_data.process_reserve_upDown_node_active is not None + assert dispatch.flex_data.process_reserve_upDown_node_active.height > 0 + + # The peaker provides the full 10 MW/step up-reserve on its fixed cap. + vr = dispatch.solution.value("v_reserve") + assert vr.height == 3 + assert all(v == pytest.approx(RESERVE_MW) for v in vr["value"].to_list()), ( + f"sibling reserve provision != {RESERVE_MW} MW/step:\n{vr}" + ) + + +# =========================================================================== +# Unit tests — the two in-memory registration primitives (no solver) +# =========================================================================== + + +def test_register_derived_solve_makes_sibling_a_real_spine_solve(tmp_path): + """``register_derived_solve`` lists the sibling in ``entities('solve')`` + with a FRESH entity id, so ``_solve_in_spine`` is True and + ``_solve_periods`` reads the cloned realized window — while NOT cloning + ``invest_periods`` (the sibling must not invest).""" + from flextool.engine_polars._spinedb_reader import SpineDbReader + from flextool.engine_polars._derived_params import ( + _solve_in_spine, + _solve_periods, + ) + + url = _build_db(AUTO_FIXTURE, tmp_path) + reader = SpineDbReader(url, scenario=SCENARIO) + + assert not _solve_in_spine(reader, SIBLING) + solve_cls = reader._class_name_to_id["solve"] + before_ids = set(reader._entity_by_id) + + reader.register_derived_solve(PARENT, SIBLING) + + # Fresh id, listed in entities('solve'), _solve_in_spine True. + new_ids = set(reader._entity_by_id) - before_ids + assert len(new_ids) == 1 + new_eid = new_ids.pop() + assert new_eid == max(before_ids) + 1 + assert reader._entity_by_id[new_eid] == (solve_cls, SIBLING) + assert SIBLING in set(reader.entities("solve")["name"].to_list()) + assert _solve_in_spine(reader, SIBLING) + + # realized window cloned; invest_periods NOT cloned. + assert _solve_periods(reader, SIBLING, "realized_periods") == ["p2035"] + assert _solve_periods(reader, PARENT, "invest_periods") == ["p2035"] + assert _solve_periods(reader, SIBLING, "invest_periods") is None + + # Idempotent — a second call is a no-op (no duplicate id). + reader.register_derived_solve(PARENT, SIBLING) + assert set(reader._entity_by_id) - before_ids == {new_eid} + + +def test_register_derived_solve_missing_parent_raises(tmp_path): + from flextool.engine_polars._spinedb_reader import SpineDbReader + from flextool.engine_polars._solve_state import FlexToolConfigError + + url = _build_db(AUTO_FIXTURE, tmp_path) + reader = SpineDbReader(url, scenario=SCENARIO) + with pytest.raises(FlexToolConfigError): + reader.register_derived_solve("no_such_solve", "no_such__realized_dispatch") + + +def test_spawn_realized_dispatch_sibling_copies_sets_and_drops(tmp_path): + """``spawn_realized_dispatch_sibling`` inserts the sibling right after + the parent in ``model_solve``, COPIES the timeline scaffolding, SETS + ``single_solve`` + the realized window, and DROPS the invest / + stochastic / decomposition keys.""" + from flextool.engine_polars._solve_config import SolveConfig + + url = _build_db(AUTO_FIXTURE, tmp_path) + sc = SolveConfig.load_from_db_url(url, SCENARIO) + + # Pre-populate a few per-solve dicts on the parent so COPY is observable. + sc.timesets_used_by_solves[PARENT] = [("p2035", "ts1")] + sc.realized_periods[PARENT] = [("p2035", "p2035")] + sc.invest_periods[PARENT] = [("p2035", "p2035")] + + model = next(iter(sc.model_solve)) + assert sc.model_solve[model] == [PARENT] + + sc.spawn_realized_dispatch_sibling(PARENT, SIBLING) + + # Positional insert right AFTER the parent. + assert sc.model_solve[model] == [PARENT, SIBLING] + # SET. + assert sc.solve_modes[SIBLING] == "single_solve" + assert sc.realized_periods[SIBLING] == sc.realized_periods[PARENT] + # COPY (deep — a distinct object). + assert sc.timesets_used_by_solves[SIBLING] == sc.timesets_used_by_solves[PARENT] + assert sc.timesets_used_by_solves[SIBLING] is not sc.timesets_used_by_solves[PARENT] + # DROP — the sibling is investment-free / monolithic. invest_periods is + # a defaultdict(list): a missing key reads back as the empty default. + assert SIBLING not in sc.invest_periods + assert sc.invest_periods[SIBLING] == [] + assert SIBLING not in sc.realized_invest_periods + assert SIBLING not in sc.fix_storage_periods + assert SIBLING not in sc.decomposition + assert SIBLING not in sc.stochastic_invest_method + assert SIBLING not in sc.contains_solves + # dispatch_realization_method absent → resolver yields within_solve → + # the run()-site guard no-ops → no re-spawn. + assert sc.dispatch_realization_method_for(SIBLING) == "within_solve" + + +def test_spawn_helper_registers_in_both_reader_and_config(tmp_path): + """The orchestration helper performs the DUAL registration: the sibling + lands in the reader's ``solve`` entities (full override path) AND right + after the parent in the driving config's ``model_solve``.""" + from flextool.engine_polars._spinedb_reader import SpineDbReader + from flextool.engine_polars._solve_config import SolveConfig + from flextool.engine_polars._orchestration import ( + _spawn_realized_dispatch_siblings, + ) + + url = _build_db(AUTO_FIXTURE, tmp_path) + reader = SpineDbReader(url, scenario=SCENARIO) + sc = SolveConfig.load_from_db_url(url, SCENARIO) + model = next(iter(sc.model_solve)) + + _spawn_realized_dispatch_siblings(sc, reader, logger=None) + + assert SIBLING in set(reader.entities("solve")["name"].to_list()) + assert sc.model_solve[model] == [PARENT, SIBLING] + assert sc.solve_modes[SIBLING] == "single_solve" + + +def test_spawn_helper_collision_raises(tmp_path): + """A pre-existing solve named ``__realized_dispatch`` (here + injected into the reader) makes the helper raise, naming the colliding + owner, rather than silently clobbering it.""" + from flextool.engine_polars._spinedb_reader import SpineDbReader + from flextool.engine_polars._solve_config import SolveConfig + from flextool.engine_polars._orchestration import ( + _spawn_realized_dispatch_siblings, + ) + from flextool.engine_polars._solve_state import FlexToolConfigError + + url = _build_db(AUTO_FIXTURE, tmp_path) + reader = SpineDbReader(url, scenario=SCENARIO) + # Inject a colliding 'solve' entity into the reader. + solve_cls = reader._class_name_to_id["solve"] + collide_eid = max(reader._entity_by_id) + 1 + reader._entities_by_class[solve_cls].append((collide_eid, SIBLING, None)) + reader._entity_by_id[collide_eid] = (solve_cls, SIBLING) + + sc = SolveConfig.load_from_db_url(url, SCENARIO) + with pytest.raises(FlexToolConfigError) as exc: + _spawn_realized_dispatch_siblings(sc, reader, logger=None) + assert SIBLING in str(exc.value) + + +def test_spawn_helper_fix_storage_guard_raises(): + """Positive storage-pinning guard (investigation outcome): a + ``separate_dispatch`` parent authoring ANY non-empty + ``fix_storage_periods`` is rejected — the auto sibling drops + ``fix_storage_periods`` (the only solve-level storage mechanism it + sheds), which would silently re-float the pinned storage boundaries. + Rejection is CONSERVATIVE and independent of realized-period count + (gating on >1 realized period would UNDER-fire for a single realized + period with a multi-period fix_storage authored). Empty / unset + ``fix_storage_periods`` is NOT rejected.""" + from flextool.engine_polars._solve_config import SolveConfig + from flextool.engine_polars._orchestration import ( + _spawn_realized_dispatch_siblings, + ) + from flextool.engine_polars._solve_state import FlexToolConfigError + + class _FakeReader: + def __init__(self): + self.registered = [] + + def entities(self, _cls): + return pl.DataFrame({"name": [PARENT]}) + + def register_derived_solve(self, parent, sibling): + self.registered.append((parent, sibling)) + + def _cfg(realized, fix_storage): + sc = object.__new__(SolveConfig) + sc.model_solve = {"m": [PARENT]} + sc.contains_solves = {} + sc.solve_modes = {PARENT: "single_solve"} + sc.dispatch_realization_method = {PARENT: "separate_dispatch"} + sc.realized_periods = {PARENT: realized} + sc.fix_storage_periods = {PARENT: fix_storage} + return sc + + # Multi-period fix_storage_periods → reject. + sc = _cfg([("p2035", "p2035"), ("p2040", "p2040")], + [("p2035", "p2035"), ("p2040", "p2040")]) + with pytest.raises(FlexToolConfigError) as exc: + _spawn_realized_dispatch_siblings(sc, _FakeReader(), logger=None) + assert "fix_storage_periods" in str(exc.value) + + # SINGLE realized period but a (2-period) fix_storage authored → still + # reject (this is exactly the case a realized-period-count gate would + # miss — the single pinned boundary is dropped by the sibling). + sc_single = _cfg([("p2035", "p2035")], + [("p2035", "p2035"), ("p2040", "p2040")]) + with pytest.raises(FlexToolConfigError) as exc: + _spawn_realized_dispatch_siblings(sc_single, _FakeReader(), logger=None) + assert "fix_storage_periods" in str(exc.value) + + # Multiple realized periods but NO fix_storage_periods → allowed + # (the monolithic solve links storage via its own balance constraints). + sc_ok = _cfg([("p2035", "p2035"), ("p2040", "p2040")], []) + reader_ok = _FakeReader() + _spawn_realized_dispatch_siblings(sc_ok, reader_ok, logger=None) + assert reader_ok.registered == [(PARENT, SIBLING)] diff --git a/tests/fixtures/build_w4b_auto_dispatch.py b/tests/fixtures/build_w4b_auto_dispatch.py new file mode 100644 index 000000000..fd24e5a8d --- /dev/null +++ b/tests/fixtures/build_w4b_auto_dispatch.py @@ -0,0 +1,84 @@ +"""Build ``tests/fixtures/w4b_auto_dispatch.json`` — the W4b +"engine-automatic realized dispatch" fixture. + +This is W8.1's ``stoch_realized_dispatch`` fixture collapsed to a SINGLE +top-level solve that authors ``dispatch_realization_method = +separate_dispatch``. Where W8.1 hand-declares a second +``stoch_rd_realized_dispatch`` solve, W4b removes that author-declared +solve entirely and lets the engine AUTOMATICALLY register the +deterministic realized-dispatch sibling ``stoch_rd__realized_dispatch``. + +Transforms applied to the W8.1 spec (so the two never drift): + + 1. ``model.solves`` = ``[stoch_rd]`` (drop the author-declared + ``stoch_rd_realized_dispatch``); + 2. remove the ``stoch_rd_realized_dispatch`` solve entity + its + parameter values; + 3. set ``solve.stoch_rd.dispatch_realization_method = + separate_dispatch`` (W4a schema). + +The engine then spawns ``stoch_rd__realized_dispatch`` in memory and it +reproduces the four W8.1 oracles. + +Run: ~/venv-spi/bin/python tests/fixtures/build_w4b_auto_dispatch.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +PARENT = "stoch_rd" +AUTHOR_DISPATCH = "stoch_rd_realized_dispatch" + + +def _enc(obj: object) -> str: + return base64.b64encode(json.dumps(obj).encode()).decode("ascii") + + +def build() -> dict: + # Import the W8.1 builder so this fixture tracks it. + import sys + + if str(FIXTURES) not in sys.path: + sys.path.insert(0, str(FIXTURES)) + from build_stoch_realized_dispatch import build as build_w8 + + spec = build_w8() + + # 1. model.solves -> [stoch_rd] only. + for pv in spec["parameter_values"]: + if pv[0] == "model" and pv[2] == "solves": + pv[3] = [_enc({"value_type": "str", "data": [PARENT]}), "array"] + + # 2. drop the author-declared dispatch solve entity + its params. + spec["entities"] = [ + e for e in spec["entities"] + if not (e[0] == "solve" and e[1] == AUTHOR_DISPATCH) + ] + spec["parameter_values"] = [ + pv for pv in spec["parameter_values"] + if not (pv[0] == "solve" and pv[1] == AUTHOR_DISPATCH) + ] + + # 3. author separate_dispatch on the surviving single top-level solve, + # on the same alternative its other params use ("init"). + spec["parameter_values"].append( + ["solve", PARENT, "dispatch_realization_method", + [_enc("separate_dispatch"), "str"], "init"] + ) + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "w4b_auto_dispatch.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/build_w4b_reserve_dispatch.py b/tests/fixtures/build_w4b_reserve_dispatch.py new file mode 100644 index 000000000..c32cfc646 --- /dev/null +++ b/tests/fixtures/build_w4b_reserve_dispatch.py @@ -0,0 +1,96 @@ +"""Build ``tests/fixtures/w4b_reserve_dispatch.json`` — the W4b +RESERVE full-path proof fixture. + +This is the W4b ``w4b_auto_dispatch`` single-solve ``separate_dispatch`` +fixture PLUS an up-reserve requirement on the ``elec`` node, provided by +the peaker. Its sole purpose is to lock in the critique's core concern: +the engine-automatic realized-dispatch sibling must take the FULL DB +override path (``_solve_in_spine`` → True), NOT the synthetic +``_`` fallback. The synthetic path returns ``None`` for +every solve-keyed helper and would silently DROP the reserve subsystem, +so a reserve variable / constraint PRESENT in the sibling's LP proves the +full path was taken. + +Reserve subsystem (all reserve/upDown/group classes are +active_by_default, so no entity_alternatives are needed): + + * reserve ``r1``, direction ``up``; + * group ``res_g`` spanning node ``elec`` (``group__node``); + * ``reserve__upDown__group[r1, up, res_g]`` — ``reserve_method = + timeseries_only``, ``reservation`` = 10 MW/step, a penalty so an + unmet requirement is a priced slack rather than infeasibility; + * ``reserve__upDown__unit__node[r1, up, peaker, elec]`` — the peaker is + a reliable provider (reliability 1, max_share 1), so the invest solve + sizes the peaker for 60 MW energy + 10 MW up-reserve headroom and the + deterministic dispatch sibling holds that reserve on the fixed + capacity. + +Run: ~/venv-spi/bin/python tests/fixtures/build_w4b_reserve_dispatch.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 4)] +RESERVE_MW = 10.0 + + +def _pack(value: object, value_type: str) -> list: + return [base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + import sys + + if str(FIXTURES) not in sys.path: + sys.path.insert(0, str(FIXTURES)) + from build_w4b_auto_dispatch import build as build_w4b + + spec = build_w4b() + + # ---- reserve entities (active_by_default classes) ------------------ + spec["entities"] += [ + ["reserve", "r1", None], + ["upDown", "up", None], + ["group", "res_g", None], + ["group__node", ["res_g", "elec"], None], + ["reserve__upDown__group", ["r1", "up", "res_g"], None], + ["reserve__upDown__unit__node", ["r1", "up", "peaker", "elec"], None], + ] + + # ---- reserve parameter values (on the 'base' alternative) ---------- + reservation = _map([(t, RESERVE_MW) for t in STEPS]) + spec["parameter_values"] += [ + ["reserve__upDown__group", ["r1", "up", "res_g"], "reserve_method", + _pack("timeseries_only", "str"), "base"], + ["reserve__upDown__group", ["r1", "up", "res_g"], "reservation", + _pack(reservation, "map"), "base"], + ["reserve__upDown__group", ["r1", "up", "res_g"], "penalty_reserve", + _pack(5000.0, "float"), "base"], + ["reserve__upDown__unit__node", ["r1", "up", "peaker", "elec"], + "reliability", _pack(1.0, "float"), "base"], + ["reserve__upDown__unit__node", ["r1", "up", "peaker", "elec"], + "max_share", _pack(1.0, "float"), "base"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "w4b_reserve_dispatch.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/w4b_auto_dispatch.json b/tests/fixtures/w4b_auto_dispatch.json new file mode 100644 index 000000000..ba1bf86ff --- /dev/null +++ b/tests/fixtures/w4b_auto_dispatch.json @@ -0,0 +1,6253 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzU=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solves and time structure (two-solve chain)" + ], + [ + "base", + "System data (peaker invest + wind + elec demand)" + ], + [ + "stoch", + "Stochastic branches + branch-varying wind profile" + ] + ], + "scenarios": [ + [ + "w8_realized_dispatch", + false, + "Stochastic invest \u2192 deterministic realized-dispatch handoff" + ] + ], + "scenario_alternatives": [ + [ + "w8_realized_dispatch", + "init", + "base" + ], + [ + "w8_realized_dispatch", + "base", + "stoch" + ], + [ + "w8_realized_dispatch", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_rd", + null + ], + [ + "timeline", + "y3h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "unit", + "peaker", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "peaker" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF9yZCJdfQ==", + "array" + ], + "init" + ], + [ + "timeline", + "y3h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "InkzaCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdXX0=", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTAwLjBdLCBbInQwMDAyIiwgLTEwMC4wXSwgWyJ0MDAwMyIsIC0xMDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "peaker", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + "other_operational_cost", + [ + "MTAuMA==", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "dispatch_realization_method", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ], + "init" + ] + ] +} diff --git a/tests/fixtures/w4b_reserve_dispatch.json b/tests/fixtures/w4b_reserve_dispatch.json new file mode 100644 index 000000000..c9533aab3 --- /dev/null +++ b/tests/fixtures/w4b_reserve_dispatch.json @@ -0,0 +1,6367 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzU=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solves and time structure (two-solve chain)" + ], + [ + "base", + "System data (peaker invest + wind + elec demand)" + ], + [ + "stoch", + "Stochastic branches + branch-varying wind profile" + ] + ], + "scenarios": [ + [ + "w8_realized_dispatch", + false, + "Stochastic invest \u2192 deterministic realized-dispatch handoff" + ] + ], + "scenario_alternatives": [ + [ + "w8_realized_dispatch", + "init", + "base" + ], + [ + "w8_realized_dispatch", + "base", + "stoch" + ], + [ + "w8_realized_dispatch", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_rd", + null + ], + [ + "timeline", + "y3h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "unit", + "peaker", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ], + [ + "reserve", + "r1", + null + ], + [ + "upDown", + "up", + null + ], + [ + "group", + "res_g", + null + ], + [ + "group__node", + [ + "res_g", + "elec" + ], + null + ], + [ + "reserve__upDown__group", + [ + "r1", + "up", + "res_g" + ], + null + ], + [ + "reserve__upDown__unit__node", + [ + "r1", + "up", + "peaker", + "elec" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "peaker" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF9yZCJdfQ==", + "array" + ], + "init" + ], + [ + "timeline", + "y3h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "InkzaCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdXX0=", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTAwLjBdLCBbInQwMDAyIiwgLTEwMC4wXSwgWyJ0MDAwMyIsIC0xMDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "peaker", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + "other_operational_cost", + [ + "MTAuMA==", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "dispatch_realization_method", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ], + "init" + ], + [ + "reserve__upDown__group", + [ + "r1", + "up", + "res_g" + ], + "reserve_method", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ], + "base" + ], + [ + "reserve__upDown__group", + [ + "r1", + "up", + "res_g" + ], + "reservation", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxMC4wXSwgWyJ0MDAwMiIsIDEwLjBdLCBbInQwMDAzIiwgMTAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "reserve__upDown__group", + [ + "r1", + "up", + "res_g" + ], + "penalty_reserve", + [ + "NTAwMC4w", + "float" + ], + "base" + ], + [ + "reserve__upDown__unit__node", + [ + "r1", + "up", + "peaker", + "elec" + ], + "reliability", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "reserve__upDown__unit__node", + [ + "r1", + "up", + "peaker", + "elec" + ], + "max_share", + [ + "MS4w", + "float" + ], + "base" + ] + ] +} From 9ad7b8642a0e6d4425fc419462d437b9c5f8e92d Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 10:04:26 +0300 Subject: [PATCH 72/87] feat(stochastic): realized-branch projection for the dispatch sibling (W4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W4 slice 3 — the hard piece, and the unblocker for W4d. The W4b auto-spawned realized-dispatch sibling is deterministic and drops stochastic_branches, so its solve_branch__time_branch scaffolding was empty; the per-solve fold that collapses a stochastic 3d_map (branch,time_start,time)->(d,t) — _derived_profile _profile_stochastic_lf / _pdt_lookup PdtLookup.get — then found no branch rows, dropped the 3d_map, and the zero-fallback filled 0 (wind->0->garbage). So the sibling only worked when realized data was deterministic (2d). This makes the sibling read each stochastic parameter PROJECTED onto the realized branch, so the stochastic solve's forecasts can genuinely diverge from the realized series. Fix (minimal, additive — no fold/_param_shapes/reader change): - SolveConfig.realized_dispatch_parent: dict[str,str], recorded by the W4b spawn_realized_dispatch_sibling (explicit map, not name-parsing). - A module-level helper project_realized_branch_time_branch_rows + a gated post-pass in _native_run_model (after create_stochastic_periods, before the per-solve emit loop): solve_branch__time_branch_lists[sibling] = the parent's rows filtered by (p,p) in period__branch_lists[parent] — reusing the parent's already-materialized realized-branch rows; the (p,p) predicate (same one derive_solve_branch_weight uses) keeps realized-named rows and excludes synthetic forecast copies. The existing folds then select the realized branch unchanged. Covers a multi-period realized window (one realized row per period via the continuation loop). The sibling stays non-genuinely-stochastic: is_genuinely_stochastic keys on period_branch_full/period_in_use_set, NOT solve_branch__time_branch, so zero branch LP variables are built (verified: sibling v_flow has 3 rows, no fan). Reviewed consumers confirm the injected rows reach only the pbt/profile fold (and derive_solve_branch_weight emits 1.0 = semantically "no branch"). Byte-parity: no-op without a sibling; inert on deterministic 2d-profile data (W4b/W8.1 fixtures byte-identical). No new var/cstr/param. Tests: test_w4c_divergent_dispatch.py — branch-distinct 3d_map wind (rlz 0.50 / high 0.80 / low 0.10); the auto-spawned sibling dispatches the REALIZED branch (wind 50, peaker 50, objective 4,380,000 at rel=1e-9) with negative controls (NOT high 20, NOT low 90, NOT collapsed 100-VOLL); parent-divergence proves the data is branch-distinct; last-wins; multi-period + empty helper unit tests; spawn records the parent map. Runs under FLEXTOOL_AUTOSCALE_STRICT=1. Next: W4d (realized operational weight->0, forecasts normalized) is now safe — realized ops come entirely from this sibling reading the projected realized series. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_native_run_model.py | 85 + flextool/engine_polars/_solve_config.py | 20 + tests/engine_polars/test_w4b_auto_dispatch.py | 2 + .../test_w4c_divergent_dispatch.py | 312 + .../fixtures/build_w4c_divergent_dispatch.py | 112 + tests/fixtures/w4c_divergent_dispatch.json | 6253 +++++++++++++++++ 6 files changed, 6784 insertions(+) create mode 100644 tests/engine_polars/test_w4c_divergent_dispatch.py create mode 100644 tests/fixtures/build_w4c_divergent_dispatch.py create mode 100644 tests/fixtures/w4c_divergent_dispatch.json diff --git a/flextool/engine_polars/_native_run_model.py b/flextool/engine_polars/_native_run_model.py index e140f3501..1840391bc 100644 --- a/flextool/engine_polars/_native_run_model.py +++ b/flextool/engine_polars/_native_run_model.py @@ -166,6 +166,42 @@ def _downgrade_rp_methods_for_non_rp_solve( ) +def project_realized_branch_time_branch_rows( + parent_time_branch_list: list[tuple[str, str]], + parent_period_branch_list: list[tuple[str, str]], +) -> list[tuple[str, str]]: + """W4c — select the parent's REALIZED-branch ``solve_branch__time_branch`` + rows for an auto-spawned realized-dispatch sibling. + + A realized-dispatch sibling is deterministic (``stochastic_branches`` + dropped), so the stochastic pass leaves its + ``solve_branch__time_branch`` scaffolding EMPTY and the per-solve folds + that collapse a stochastic 3d_map drop it (wind -> 0). This re-selects + the parent's already-materialized realized-branch rows so the folds + read the realized branch unchanged. + + *parent_time_branch_list* is the parent's + ``solve_branch__time_branch_lists`` entry — realized rows are + ``(period, realized_branch)`` (first element == the REAL period name), + forecast rows are ``(_, branch)``. + *parent_period_branch_list* is the parent's ``period__branch_lists`` + entry — the realized branch owns a ``(period, period)`` self-row per + period. A row is kept iff its period has a ``(p, p)`` self-row in the + period-branch list (the SAME realized-row predicate + ``derive_solve_branch_weight`` uses), which selects the realized rows + and excludes the synthetic forecast rows. A MULTI-period realized + window therefore yields one realized row per realized period (the + continuation loop appends one ``(period, realized_branch)`` per + continued period). + """ + period_branch_set = set(parent_period_branch_list) + return [ + (period, branch) + for (period, branch) in parent_time_branch_list + if (period, period) in period_branch_set + ] + + def native_run_model(state, solver) -> int: """Drive the per-solve cascade natively. @@ -351,6 +387,55 @@ def native_run_model(state, solver) -> int: if not realized_time_lists[solve][period]: del realized_time_lists[solve][period] + # ------------------------------------------------------------------ + # 3.6 W4c — project each stochastic parameter onto the REALIZED branch + # for auto-spawned realized-dispatch siblings. + # + # A W4b realized-dispatch sibling (``__realized_dispatch``) is + # deterministic and drops ``stochastic_branches``, so the stochastic + # pass above leaves its ``solve_branch__time_branch_lists`` EMPTY. The + # per-solve folds that collapse a stochastic 3d_map + # ``(branch, time_start, time) -> (d, t)`` — ``_derived_profile``'s + # ``_profile_stochastic_lf`` and ``_pdt_lookup.PdtLookup.get`` — then + # find no branch rows, drop the 3d_map, and the zero-fallback fills 0 + # (wind -> 0 -> garbage). Re-populate the sibling's branch rows from + # the parent's ALREADY-materialized realized-branch rows: the + # ``(p, p) in period__branch_lists[parent]`` predicate selects the + # realized self-rows (the SAME predicate ``derive_solve_branch_weight`` + # uses at ``_emit_solve_writers.py``) and excludes synthetic forecast + # rows (``

_high`` ...). A MULTI-period realized window carries one + # realized-branch row per realized period (the continuation loop in + # ``_stochastic.py`` appends ``(period, realized_branch)`` for each), so + # the filter keeps each — the folds then select the realized branch + # unchanged, in every realized period. + # + # This is purely a scaffolding injection: no new var/cstr/param, no + # ``_param_shapes`` routing change, and the sibling stays + # NON-genuinely-stochastic (``is_genuinely_stochastic`` keys on + # ``period_branch_full`` / ``period_in_use_set``, which are unchanged + # here, NOT ``solve_branch__time_branch``) -> zero branch LP variables. + # Gated on ``realized_dispatch_parent`` so it is a strict no-op when no + # sibling is spawned (byte-parity on deterministic 2d-profile fixtures); + # parent-absent / parent-empty cases are handled gracefully. + # ------------------------------------------------------------------ + realized_dispatch_parent = getattr( + state.solve, "realized_dispatch_parent", {} + ) or {} + for sibling, parent in realized_dispatch_parent.items(): + if sibling not in solve_branch__time_branch_lists: + continue + if ( + parent not in solve_branch__time_branch_lists + or parent not in period__branch_lists + ): + continue + solve_branch__time_branch_lists[sibling] = ( + project_realized_branch_time_branch_rows( + solve_branch__time_branch_lists[parent], + period__branch_lists[parent], + ) + ) + for solve in active_time_lists.keys(): for period in active_time_lists[solve]: if (period, period) in period__branch_lists[solve] and not any( diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 0e8570830..0601018f7 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -353,6 +353,20 @@ def __init__( # byte-parity-safe (both empty-ish states resolve to no ties). self.non_anticipativity_invest_periods: defaultdict = defaultdict(list) + # W4c — maps an auto-spawned realized-dispatch sibling name to its + # parent solve name. Populated by + # :meth:`spawn_realized_dispatch_sibling`; consumed by the W4c + # post-pass in ``_native_run_model`` to project each stochastic + # parameter onto the parent's REALIZED branch. The sibling is + # deterministic (``stochastic_branches`` dropped), so + # ``create_stochastic_periods`` leaves its + # ``solve_branch__time_branch`` scaffolding empty; the post-pass + # reuses THIS explicit map (never name-parsing the suffix) to + # re-populate it from the parent's realized-branch rows. Empty + # when no sibling is spawned (the post-pass then no-ops, preserving + # byte-parity on deterministic 2d-profile fixtures). + self.realized_dispatch_parent: dict[str, str] = {} + # Mutable tracking — populated during the recursive solve loop. self.real_solves: list[str] = [] self.first_of_complete_solve: list[str] = [] @@ -993,6 +1007,12 @@ def spawn_realized_dispatch_sibling( self.realized_periods[sibling] = copy.deepcopy( self.realized_periods.get(parent, []) ) + # W4c — record the explicit sibling -> parent mapping so the + # native-run post-pass can project each stochastic parameter onto + # the parent's realized branch (see ``_native_run_model`` step 3.6). + # Using this map (not a ``__realized_dispatch`` name-parse) keeps + # the sibling identification robust. + self.realized_dispatch_parent[sibling] = parent # ------------------------------------------------------------------ # v60/v62 per-solve decomposition accessors diff --git a/tests/engine_polars/test_w4b_auto_dispatch.py b/tests/engine_polars/test_w4b_auto_dispatch.py index 17816c023..b9430ef95 100644 --- a/tests/engine_polars/test_w4b_auto_dispatch.py +++ b/tests/engine_polars/test_w4b_auto_dispatch.py @@ -473,6 +473,8 @@ def _cfg(realized, fix_storage): sc.dispatch_realization_method = {PARENT: "separate_dispatch"} sc.realized_periods = {PARENT: realized} sc.fix_storage_periods = {PARENT: fix_storage} + # W4c — spawn records the sibling -> parent map on this dict. + sc.realized_dispatch_parent = {} return sc # Multi-period fix_storage_periods → reject. diff --git a/tests/engine_polars/test_w4c_divergent_dispatch.py b/tests/engine_polars/test_w4c_divergent_dispatch.py new file mode 100644 index 000000000..53ee98b8a --- /dev/null +++ b/tests/engine_polars/test_w4c_divergent_dispatch.py @@ -0,0 +1,312 @@ +"""W4c — the auto-spawned realized-dispatch sibling reads each stochastic +parameter PROJECTED onto the REALIZED branch. + +W4b proved the engine auto-spawns a deterministic realized-dispatch +sibling (``__realized_dispatch``) that consumes the parent's +committed capacity and owns the realized window — but only when the +realized operational data was DETERMINISTIC (a 2d profile). A +branch-distinct stochastic 3d_map ``(branch, time_start, time) -> value`` +was DROPPED by the sibling (no branch scaffolding -> zero-fallback -> +wind 0 -> VOLL), so forecasts could not diverge from the realized data. + +W4c closes that gap: a post-pass in ``_native_run_model`` projects the +parent's REALIZED-branch ``solve_branch__time_branch`` rows onto the +sibling (keyed off the explicit ``SolveConfig.realized_dispatch_parent`` +map, recorded by ``spawn_realized_dispatch_sibling``). The per-solve +folds then select the realized branch unchanged, and the sibling stays +NON-genuinely-stochastic (zero branch LP variables). + +This module pins: + + * the divergent-forecast e2e: with wind profile rlz=0.50 / high=0.80 / + low=0.10, the sibling dispatches the REALIZED rlz=0.50 (wind 50 MW, + peaker 50 MW/step, objective 4 380 000) — NOT a forecast (80/20 or + 10/90) and NOT the collapsed-to-0 VOLL fallback; + * last-wins: the parent's realized p2035 window is emptied and the + sibling owns p2035/t0001-t0003; + * the spawn primitive records ``realized_dispatch_parent[sibling]``; + * the projection helper carries ONE realized row per realized period + for a MULTI-period realized window (continuation-loop coverage). +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import polars as pl +import pytest + +FLEXTOOL_ROOT = Path(__file__).resolve().parents[2] +DIVERGENT_FIXTURE = ( + FLEXTOOL_ROOT / "tests" / "fixtures" / "w4c_divergent_dispatch.json" +) + +SCENARIO = "w8_realized_dispatch" +PARENT = "stoch_rd" +SIBLING = "stoch_rd__realized_dispatch" +REALIZED_STEPS = {"t0001", "t0002", "t0003"} + +# Hand-calc constants (realized rlz branch: wind 0.50 -> 50 MW, residual +# 50 MW peaker, annualized over 8760 h at 10 CUR/MWh). +EXISTING_WIND_MW = 100.0 +RLZ_PROFILE = 0.50 +WIND_MW = RLZ_PROFILE * EXISTING_WIND_MW # 50 MW +PEAKER_MW = 100.0 - WIND_MW # 50 MW residual +HOURS_PER_YEAR = 8760.0 +PEAKER_VAR_COST = 10.0 +DISPATCH_OBJECTIVE = PEAKER_MW * PEAKER_VAR_COST * HOURS_PER_YEAR # 4 380 000 + +# Negative-control peaker levels the sibling must NOT land on. +PEAKER_HIGH_MW = 100.0 - 0.80 * EXISTING_WIND_MW # 20 MW (high forecast) +PEAKER_LOW_MW = 100.0 - 0.10 * EXISTING_WIND_MW # 90 MW (low forecast) +PEAKER_COLLAPSED_MW = 100.0 # wind 0 -> all peaker + + +def _build_db(fixture_json: Path, tmp_path: Path) -> str: + """Build-from-JSON + migrate (CLAUDE.md invariant 3; never read a + checked-in .sqlite).""" + if not fixture_json.exists(): + pytest.skip(f"fixture JSON not present: {fixture_json}") + tests_dir = FLEXTOOL_ROOT / "tests" + if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) + from db_utils import json_to_db # noqa: E402 + from flextool.update_flextool.db_migration import migrate_database + + db_path = tmp_path / f"{fixture_json.stem}.sqlite" + url = json_to_db(fixture_json, db_path) + migrate_database(url) + return url + + +def _run_chain(fixture_json: Path, tmp_path: Path): + """Run the single-solve chain once under ``FLEXTOOL_AUTOSCALE_STRICT=1`` + with ``warm=False`` + ``keep_solutions=True``; return + ``(steps, work_folder)``.""" + from flextool.engine_polars import run_chain_from_db + + url = _build_db(fixture_json, tmp_path) + work_folder = tmp_path / "work" + work_folder.mkdir() + + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + steps = run_chain_from_db( + url, SCENARIO, work_folder=work_folder, + warm=False, keep_solutions=True, + ) + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + assert steps, "run_chain_from_db produced no orchestration steps" + return steps, work_folder + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("w4c_divergent_dispatch") + return _run_chain(DIVERGENT_FIXTURE, tmp_path) + + +def _step(steps, needle): + key = next((k for k in steps if k == needle), None) or next( + (k for k in steps if needle in k), None + ) + assert key is not None, f"no {needle!r} step in {list(steps)}" + return steps[key] + + +# =========================================================================== +# (1) Divergent-forecast e2e — the sibling reads the REALIZED branch +# =========================================================================== + + +@pytest.mark.solver +def test_sibling_reads_realized_branch_not_forecast(chain) -> None: + """With branch-distinct wind (rlz 0.50 / high 0.80 / low 0.10) the + auto sibling dispatches the REALIZED rlz=0.50: wind 50 MW, peaker + 50 MW/step. NOT a forecast (80/20 or 10/90) and NOT the collapsed + wind-0 VOLL fallback (wind > 0).""" + steps, _wf = chain + dispatch = _step(steps, SIBLING) + assert dispatch.solution is not None and dispatch.optimal + + # Deterministic sibling — zero branch LP variables, no v_invest. + disp_vars = set(getattr(dispatch.solution, "_vars", {}).keys()) + assert not any("v_invest" in v for v in disp_vars), ( + f"sibling built v_invest variables {sorted(disp_vars)}" + ) + + flows = dispatch.solution.value("v_flow") + peaker = flows.filter(pl.col("source") == "peaker") + wind = flows.filter(pl.col("source") == "wind") + assert peaker.height == 3 + assert wind.height == 3 + + peaker_vals = peaker["value"].to_list() + wind_vals = wind["value"].to_list() + + # Realized rlz: peaker 50 MW/step; wind v_flow is the profile fraction + # (physical wind = 0.50 * 100 existing = 50 MW). + assert all(v == pytest.approx(PEAKER_MW) for v in peaker_vals), ( + f"sibling peaker != {PEAKER_MW} MW/step (realized rlz=0.50): " + f"{peaker_vals}" + ) + assert all(v == pytest.approx(RLZ_PROFILE) for v in wind_vals), ( + f"sibling wind fraction != {RLZ_PROFILE} (realized rlz): {wind_vals}" + ) + + # NEGATIVE CONTROLS — not a forecast, not the collapsed-to-0 fallback. + for v in peaker_vals: + assert v != pytest.approx(PEAKER_HIGH_MW), "sibling read the HIGH forecast" + assert v != pytest.approx(PEAKER_LOW_MW), "sibling read the LOW forecast" + assert v != pytest.approx(PEAKER_COLLAPSED_MW), ( + "sibling collapsed to wind=0 (3d_map dropped) -> all-peaker VOLL" + ) + for v in wind_vals: + assert v > 0.0, "wind collapsed to 0 — the realized 3d_map was dropped" + + +@pytest.mark.solver +def test_sibling_objective_hand_calc(chain) -> None: + """Sibling objective = 50 MW * 8760 h * 10 CUR/MWh = 4 380 000 at + rel=1e-9. This single value excludes every negative control (any + forecast level or a VOLL collapse would move it well outside 1e-9).""" + steps, wf = chain + _step(steps, SIBLING) # ensure the sibling step exists + obj = float( + pl.read_parquet(wf / "output_raw" / f"v_obj__{SIBLING}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(DISPATCH_OBJECTIVE, rel=1e-9) + # Sanity: nowhere near a VOLL collapse (which would add ~50 MW unserved + # * 8760 h * 10000 CUR/MWh on top of fuel). + assert obj < DISPATCH_OBJECTIVE * 100.0 + + +@pytest.mark.solver +def test_parent_forecasts_genuinely_diverge(chain) -> None: + """The parent is genuinely stochastic and its branches diverge: peaker + hits 50 (rlz), 20 (high) and 90 (low) MW — proving the realized and + forecast data are DISTINCT, so the sibling's 50 MW is a real + realized-branch projection, not an accidental shared value.""" + steps, _wf = chain + parent = _step(steps, PARENT) + assert parent.solution is not None and parent.optimal + flows = parent.solution.value("v_flow") + peaker_vals = set( + round(v, 6) + for v in flows.filter(pl.col("source") == "peaker")["value"].to_list() + ) + for expected in (PEAKER_MW, PEAKER_HIGH_MW, PEAKER_LOW_MW): + assert any(v == pytest.approx(expected) for v in peaker_vals), ( + f"parent peaker levels {sorted(peaker_vals)} missing {expected}" + ) + + +@pytest.mark.solver +def test_realized_window_owned_by_sibling(chain) -> None: + """Last-wins: the parent's realized p2035 window is emptied; the + sibling owns p2035/t0001-t0003.""" + steps, _wf = chain + parent = _step(steps, PARENT) + dispatch = _step(steps, SIBLING) + + parent_rd = parent.flex_data.realized_dispatch + if parent_rd is not None: + remaining = parent_rd.filter(pl.col("period") == "p2035") + assert remaining.height == 0, ( + "parent still owns realized p2035 cells — last-wins did not hand " + f"them to the auto sibling:\n{parent_rd}" + ) + + disp_rd = dispatch.flex_data.realized_dispatch + assert disp_rd is not None and disp_rd.height > 0 + owned = disp_rd.filter(pl.col("period") == "p2035") + assert set(owned["step"].to_list()) == REALIZED_STEPS + + +# =========================================================================== +# (2) Spawn primitive records the sibling -> parent map +# =========================================================================== + + +def test_spawn_records_realized_dispatch_parent(tmp_path): + """``spawn_realized_dispatch_sibling`` records the EXPLICIT sibling -> + parent mapping consumed by the W4c post-pass (no ``__realized_dispatch`` + name-parse).""" + from flextool.engine_polars._solve_config import SolveConfig + + url = _build_db(DIVERGENT_FIXTURE, tmp_path) + sc = SolveConfig.load_from_db_url(url, SCENARIO) + + assert sc.realized_dispatch_parent == {} + sc.spawn_realized_dispatch_sibling(PARENT, SIBLING) + assert sc.realized_dispatch_parent[SIBLING] == PARENT + + +# =========================================================================== +# (3) Multi-period realized window — one realized row per realized period +# =========================================================================== + + +def test_projection_helper_multi_period_realized_window(): + """Lock the continuation-loop coverage: for a 2-period realized window + the projection carries ONE realized-branch row per realized period and + excludes every synthetic forecast row — in parent order.""" + from flextool.engine_polars._native_run_model import ( + project_realized_branch_time_branch_rows, + ) + + # Parent ``solve_branch__time_branch`` for a 2-period fan-at-first: + # forecast rows (_, branch) + realized rows + # (period, realized_branch) for BOTH p2035 and p2040 (the continuation + # loop appends one realized row per continued period). + parent_time_branch = [ + ("p2035_high", "high"), + ("p2035_low", "low"), + ("p2035", "rlz"), + ("p2040_high", "high"), + ("p2040_low", "low"), + ("p2040", "rlz"), + ] + # Parent ``period__branch``: realized self-row (period, period) per + # period + fan rows (period, _). + parent_period_branch = [ + ("p2035", "p2035"), + ("p2035", "p2035_high"), + ("p2035", "p2035_low"), + ("p2040", "p2040"), + ("p2040", "p2040_high"), + ("p2040", "p2040_low"), + ] + + projected = project_realized_branch_time_branch_rows( + parent_time_branch, parent_period_branch + ) + assert projected == [("p2035", "rlz"), ("p2040", "rlz")] + + +def test_projection_helper_single_period_and_empty(): + """Single-period realized window -> one realized row; a parent with no + realized self-row (defensive / parent-empty) -> empty projection (no + crash).""" + from flextool.engine_polars._native_run_model import ( + project_realized_branch_time_branch_rows, + ) + + single = project_realized_branch_time_branch_rows( + [("p2035_high", "high"), ("p2035", "rlz")], + [("p2035", "p2035"), ("p2035", "p2035_high")], + ) + assert single == [("p2035", "rlz")] + + # No (p, p) self-row at all (e.g. an empty / pre-branch parent) -> []. + assert project_realized_branch_time_branch_rows([], []) == [] + assert project_realized_branch_time_branch_rows( + [("p2035_high", "high")], [("p2035", "p2035_high")] + ) == [] diff --git a/tests/fixtures/build_w4c_divergent_dispatch.py b/tests/fixtures/build_w4c_divergent_dispatch.py new file mode 100644 index 000000000..75477cf2a --- /dev/null +++ b/tests/fixtures/build_w4c_divergent_dispatch.py @@ -0,0 +1,112 @@ +"""Build ``tests/fixtures/w4c_divergent_dispatch.json`` — the W4c +"divergent-forecast realized dispatch" fixture. + +W4b (``build_w4b_auto_dispatch.py``) kept the realized operational data +DETERMINISTIC (a single 2d ``0.40`` wind profile shared by every forecast +branch), because the deterministic realized-dispatch sibling had no branch +scaffolding and would DROP a branch-distinct 3d_map (wind -> 0 -> VOLL). + +W4c removes that limitation: the engine now PROJECTS each stochastic +parameter onto the parent's REALIZED branch for the auto-spawned sibling. +This fixture is W4b with the wind ``profile`` authored as a +branch-DISTINCT 3-deep Map ``branch -> time_start -> time -> value``: + + rlz -> 0.50 (the REALIZED branch the sibling must read) + high -> 0.80 + low -> 0.10 + +The three values are mutually distinct AND distinct from W4b's old 0.40, +so the proof is unambiguous: the sibling reads 0.50 (not 0.40, not any +forecast, not the collapsed 0 fallback). Branch weights are unchanged +(rlz 0.25 / high 0.375 / low 0.375 — SUM 1.0 per cohort). + +Hand calculation (sibling dispatch solve) +----------------------------------------- +Realized data (rlz branch): demand 100 MW/step, wind cap 0.50 * 100 = 50 +MW/step (cost 0), peaker fills residual 50 MW/step (cost 10). The 3-step +timeset ANNUALIZES (timeset_duration=3 over years_represented=1 -> each +step represents 8760/3 h), so + operational objective = 50 MW * 8760 h * 10 CUR/MWh = 4 380 000 CUR. + +Negative controls (NOT reached by the sibling): 0.80/0.20 (high branch), +0.10/0.90 (low branch), 0/1.00-with-VOLL (the pre-W4c collapsed fallback +where the 3d_map is dropped and wind -> 0 -> 50 MW unserved at VOLL). + +Run: ~/venv-spi/bin/python tests/fixtures/build_w4c_divergent_dispatch.py +""" +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 4)] + +# Branch-distinct realized/forecast wind-profile fractions. +RLZ_PROFILE = 0.50 +HIGH_PROFILE = 0.80 +LOW_PROFILE = 0.10 + + +def _enc(obj: object) -> str: + return base64.b64encode(json.dumps(obj).encode()).decode("ascii") + + +def _leaf(val: float) -> dict: + """Nested (typed) Map: time -> value, for every step.""" + return {"type": "map", "index_type": "str", + "data": [[t, val] for t in STEPS]} + + +def _branch_profile() -> dict: + """3-deep ``branch -> time_start -> time -> value`` Map (time_start is + the fan-at-first branching step ``t0001``).""" + return { + "index_type": "str", + "data": [ + ["rlz", {"type": "map", "index_type": "str", + "data": [["t0001", _leaf(RLZ_PROFILE)]]}], + ["high", {"type": "map", "index_type": "str", + "data": [["t0001", _leaf(HIGH_PROFILE)]]}], + ["low", {"type": "map", "index_type": "str", + "data": [["t0001", _leaf(LOW_PROFILE)]]}], + ], + } + + +def build() -> dict: + # Import the W4b builder so this fixture tracks it. + if str(FIXTURES) not in sys.path: + sys.path.insert(0, str(FIXTURES)) + from build_w4b_auto_dispatch import build as build_w4b + + spec = build_w4b() + + # Override the wind profile with the branch-DISTINCT 3d_map, authored + # on the ``stoch`` alternative (wins over the base 0.40 and is foldable + # only by the genuinely stochastic parent / the W4c-projected sibling). + bp = _branch_profile() + found = False + for pv in spec["parameter_values"]: + if pv[0] == "profile" and pv[1] == "wprof" and pv[2] == "profile": + pv[3] = [_enc(bp), "map"] + pv[4] = "stoch" + found = True + if not found: + raise SystemExit("wprof profile value not found in W4b spec") + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "w4c_divergent_dispatch.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/w4c_divergent_dispatch.json b/tests/fixtures/w4c_divergent_dispatch.json new file mode 100644 index 000000000..ea61750f6 --- /dev/null +++ b/tests/fixtures/w4c_divergent_dispatch.json @@ -0,0 +1,6253 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzU=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solves and time structure (two-solve chain)" + ], + [ + "base", + "System data (peaker invest + wind + elec demand)" + ], + [ + "stoch", + "Stochastic branches + branch-varying wind profile" + ] + ], + "scenarios": [ + [ + "w8_realized_dispatch", + false, + "Stochastic invest \u2192 deterministic realized-dispatch handoff" + ] + ], + "scenario_alternatives": [ + [ + "w8_realized_dispatch", + "init", + "base" + ], + [ + "w8_realized_dispatch", + "base", + "stoch" + ], + [ + "w8_realized_dispatch", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_rd", + null + ], + [ + "timeline", + "y3h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "unit", + "peaker", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "peaker" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF9yZCJdfQ==", + "array" + ], + "init" + ], + [ + "timeline", + "y3h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "InkzaCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_rd", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdXX0=", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTAwLjBdLCBbInQwMDAyIiwgLTEwMC4wXSwgWyJ0MDAwMyIsIC0xMDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuNV0sIFsidDAwMDIiLCAwLjVdLCBbInQwMDAzIiwgMC41XV19XV19XSwgWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuOF0sIFsidDAwMDIiLCAwLjhdLCBbInQwMDAzIiwgMC44XV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC4xXSwgWyJ0MDAwMiIsIDAuMV0sIFsidDAwMDMiLCAwLjFdXX1dXX1dXX0=", + "map" + ], + "stoch" + ], + [ + "unit", + "peaker", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "peaker", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "peaker", + "elec" + ], + "other_operational_cost", + [ + "MTAuMA==", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "dispatch_realization_method", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ], + "init" + ] + ] +} From c503f7d58094a14533b3e0f50276fe3623bf1b76 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 11:25:36 +0300 Subject: [PATCH 73/87] docs(stochastic): consolidate the realized-dispatch feature (coherence pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-fracture pass over the accumulated separate_dispatch feature (W4a-W4c) before W4d — no behavior change (doc/comment + one guard test only). The structure was already coherent (single orchestrator entry point _spawn_realized_dispatch_siblings; single authoritative sibling->parent map SolveConfig.realized_dispatch_parent that W4c reads; clean reader/SolveConfig responsibility split). The one genuine, drift-prone fracture: "what a realized-dispatch sibling inherits from its parent" is defined TWICE in two vocabularies with nothing tying them — the reader's RAW Spine-param allow-list (_REALIZED_DISPATCH_SIBLING_PARAMS, for the full override path) and the DERIVED SolveConfig-dict copy/set in spawn_realized_dispatch_sibling (for native_run_model). The two layers genuinely need raw vs derived forms (so no clean single-list de-dup without risky surgery on working code), but the four DEFINING facts must agree: period_timeset<->timesets_used_by_solves, years_represented<->solve_period_years_represented, solve_mode<->solve_modes, realized_periods<->realized_periods. This pass: - adds a single FEATURE MAP anchor at _spawn_realized_dispatch_siblings (the entry point) enumerating every touch-point (schema/guard/registration/ projection/weighting) so the feature is navigable from one place, and names the dual-definition invariant + the W4d directive (reuse the authoritative realized_dispatch_parent / per-solve channel, do not add a parallel sibling-detection); - cross-references the two config halves (reader allow-list <-> SolveConfig copy/set) so each points at the other and at the feature map; - adds tests/engine_polars/test_realized_dispatch_coherence.py pinning the dual-definition invariant: the reader allow-list == the four defining facts, and spawn populates every defining fact's derived dict consistently with the parent. Either half drifting now fails loudly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_orchestration.py | 37 ++++++ flextool/engine_polars/_solve_config.py | 8 ++ flextool/engine_polars/_spinedb_reader.py | 10 ++ .../test_realized_dispatch_coherence.py | 105 ++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 tests/engine_polars/test_realized_dispatch_coherence.py diff --git a/flextool/engine_polars/_orchestration.py b/flextool/engine_polars/_orchestration.py index 497eba983..41a79a7f9 100644 --- a/flextool/engine_polars/_orchestration.py +++ b/flextool/engine_polars/_orchestration.py @@ -1667,6 +1667,43 @@ def _spawn_realized_dispatch_siblings( sibling for every eligible top-level solve that authors ``dispatch_realization_method = separate_dispatch``. + ════════════════════════════════════════════════════════════════════ + REALIZED-DISPATCH FEATURE MAP (the single anchor for all touch-points) + ════════════════════════════════════════════════════════════════════ + The ``separate_dispatch`` feature (spec ``stochastics_reassessment.md`` + §B/§H; slices W4a-W4d) is split across these pieces — THIS function is + the entry point that ties them together: + + * schema (W4a): ``solve.dispatch_realization_method`` {within_solve, + separate_dispatch} — migration ``_migrate_v75_...``, accessor + ``SolveConfig.dispatch_realization_method_for``. + * validation: ``_dispatch_realization_raw_guard`` (both wired sites) — + accepts separate_dispatch only for a single top-level single_solve. + * registration (W4b): THIS function → for each eligible parent, a DUAL + in-memory registration of the sibling ``__realized_dispatch``: + - ``SpineDbReader.register_derived_solve`` — clones the parent's + RAW Spine params (``_REALIZED_DISPATCH_SIBLING_PARAMS``) so the + sibling takes the full override path (``_solve_in_spine`` True); + - ``SolveConfig.spawn_realized_dispatch_sibling`` — inserts the + sibling after the parent in ``model_solve`` and copies the + DERIVED SolveConfig scaffolding dicts; records the authoritative + ``SolveConfig.realized_dispatch_parent[sibling] = parent``. + ►► DUAL-DEFINITION INVARIANT: the reader's raw-param allow-list and + the SolveConfig copy/set lists describe the SAME sibling config in + two vocabularies (period_timeset↔timesets_used_by_solves, + years_represented↔solve_period_years_represented, + solve_mode↔solve_modes, realized_periods↔realized_periods). They + MUST stay aligned — pinned by + ``tests/engine_polars/test_realized_dispatch_coherence.py``. + * projection (W4c): ``_native_run_model`` step 3.6 reads + ``realized_dispatch_parent`` and projects each stochastic param onto + the parent's realized branch (``project_realized_branch_time_branch_rows``). + * weighting (W4d): the parent's realized OPERATIONAL weight — keyed off + the parent's ``dispatch_realization_method`` — is de-scenario'd in the + ``pdt`` producers; reuse ``realized_dispatch_parent`` / the existing + per-solve channel, do NOT add a parallel sibling-detection. + ════════════════════════════════════════════════════════════════════ + Called ONCE from :func:`_drive_cascade` (after ``cascade_db_reader`` is built, BEFORE ``native_run_model``). For each eligible parent it performs a DUAL in-memory registration so the sibling takes the FULL diff --git a/flextool/engine_polars/_solve_config.py b/flextool/engine_polars/_solve_config.py index 0601018f7..7d94893ac 100644 --- a/flextool/engine_polars/_solve_config.py +++ b/flextool/engine_polars/_solve_config.py @@ -986,6 +986,14 @@ def spawn_realized_dispatch_sibling( trajectory over its realized periods is a correct free deterministic optimisation, not a re-floated gap. """ + # ►► DUAL-DEFINITION INVARIANT (fracture guard): the dicts copied/set + # below are the DERIVED-dict half of the sibling config; the RAW Spine + # half is ``SpineDbReader._REALIZED_DISPATCH_SIBLING_PARAMS``. The four + # DEFINING facts must agree across both halves (period_timeset↔ + # timesets_used_by_solves, years_represented↔solve_period_years_represented, + # solve_mode↔solve_modes, realized_periods↔realized_periods). Feature map: + # ``_orchestration._spawn_realized_dispatch_siblings``; alignment pinned by + # ``tests/engine_polars/test_realized_dispatch_coherence.py``. # Positional insert right after the parent (NOT append + remove). for _model, solves in self.model_solve.items(): if parent in solves and sibling not in solves: diff --git a/flextool/engine_polars/_spinedb_reader.py b/flextool/engine_polars/_spinedb_reader.py index 8917f592e..5b790a847 100644 --- a/flextool/engine_polars/_spinedb_reader.py +++ b/flextool/engine_polars/_spinedb_reader.py @@ -306,6 +306,16 @@ def __repr__(self) -> str: #: ``stochastic_branches`` / ``fix_storage_periods`` / decomposition / #: benders knobs are intentionally NOT copied so the sibling cannot #: invest, branch, or decompose — it is a pure fixed-capacity dispatch. + #: + #: ►► DUAL-DEFINITION INVARIANT (fracture guard): these RAW Spine params + #: are the reader-side half of the sibling config; the DERIVED-dict half + #: lives in ``SolveConfig.spawn_realized_dispatch_sibling`` (the same + #: facts in SolveConfig vocabulary — period_timeset↔timesets_used_by_solves, + #: years_represented↔solve_period_years_represented, solve_mode↔solve_modes, + #: realized_periods↔realized_periods). Keep the two aligned; the feature + #: map lives at ``_orchestration._spawn_realized_dispatch_siblings`` and + #: the alignment is pinned by + #: ``tests/engine_polars/test_realized_dispatch_coherence.py``. _REALIZED_DISPATCH_SIBLING_PARAMS = ( "period_timeset", "realized_periods", diff --git a/tests/engine_polars/test_realized_dispatch_coherence.py b/tests/engine_polars/test_realized_dispatch_coherence.py new file mode 100644 index 000000000..383467d65 --- /dev/null +++ b/tests/engine_polars/test_realized_dispatch_coherence.py @@ -0,0 +1,105 @@ +"""Coherence / fracture-guard for the realized-dispatch (``separate_dispatch``) +feature. + +The sibling's configuration is DEFINED TWICE, in two vocabularies (see the +feature map at ``_orchestration._spawn_realized_dispatch_siblings``): + +* the RAW Spine-param half — ``SpineDbReader._REALIZED_DISPATCH_SIBLING_PARAMS`` + (cloned so the sibling takes the full override path), and +* the DERIVED-dict half — the copy/set in + ``SolveConfig.spawn_realized_dispatch_sibling`` (for ``native_run_model``). + +The four DEFINING facts must agree across both halves: +``period_timeset`` ↔ ``timesets_used_by_solves``, +``years_represented`` ↔ ``solve_period_years_represented``, +``solve_mode`` ↔ ``solve_modes``, ``realized_periods`` ↔ ``realized_periods``. + +Nothing structurally forces the two lists to stay aligned (they live in +different layers / vocabularies), so this test is the fracture guard: adding a +defining fact to one half without the other, or drifting the reader allow-list, +fails here loudly. +""" +from __future__ import annotations + +from flextool.engine_polars._solve_config import SolveConfig +from flextool.engine_polars._spinedb_reader import SpineDbReader + +#: The four defining realized-dispatch facts, RAW Spine param -> DERIVED +#: SolveConfig dict. This mapping IS the single declaration of the +#: dual-definition correspondence the two halves must honour. +_DEFINING_FACT_MAP = { + "period_timeset": "timesets_used_by_solves", + "years_represented": "solve_period_years_represented", + "solve_mode": "solve_modes", + "realized_periods": "realized_periods", +} + + +def test_reader_allow_list_matches_the_defining_facts(): + """The reader's raw-param allow-list is exactly the four defining facts. + + If this drifts, update ``_DEFINING_FACT_MAP`` AND the SolveConfig + ``spawn_realized_dispatch_sibling`` copy/set in lockstep. + """ + assert set(SpineDbReader._REALIZED_DISPATCH_SIBLING_PARAMS) == set( + _DEFINING_FACT_MAP + ), ( + "SpineDbReader._REALIZED_DISPATCH_SIBLING_PARAMS drifted from the four " + "defining realized-dispatch facts — the reader half and the SolveConfig " + "half of the sibling config are no longer in lockstep (feature map: " + "_orchestration._spawn_realized_dispatch_siblings)." + ) + + +def _parent_solveconfig_stub() -> SolveConfig: + """A bare SolveConfig carrying only what ``spawn_realized_dispatch_sibling`` + reads/writes, with a single parent solve ``P`` populated.""" + sc = object.__new__(SolveConfig) + sc.model_solve = {"m": ["P"]} + sc.timesets_used_by_solves = {"P": ["ts1"]} + sc.solve_period_years_represented = {"P": {"p2035": 1.0}} + sc.periods_available = {"P": ["p2035"]} + sc.delay_durations = {} + sc.hole_multipliers = {} + sc.rolling_times = {} + sc.solve_modes = {"P": "single_solve"} + sc.realized_periods = {"P": ["p2035"]} + sc.realized_dispatch_parent = {} + return sc + + +def test_spawn_populates_every_defining_fact_derived_dict(): + """After spawn, every defining fact's DERIVED SolveConfig dict carries the + sibling — i.e. the SolveConfig half covers the same facts the reader half + clones. A fact present in the reader allow-list but not populated here = a + half-configured sibling (the fracture).""" + sc = _parent_solveconfig_stub() + sib = "P__realized_dispatch" + sc.spawn_realized_dispatch_sibling("P", sib) + + for raw, derived in _DEFINING_FACT_MAP.items(): + d = getattr(sc, derived) + assert sib in d, ( + f"defining fact {raw!r} (SolveConfig dict {derived!r}) is in the " + f"reader allow-list but spawn_realized_dispatch_sibling did not " + f"populate it for the sibling — the two halves have drifted." + ) + + +def test_spawn_sibling_config_is_consistent_with_parent(): + """The sibling's defining facts agree with the parent (solve_mode SET to + single_solve; the rest copied), and the authoritative parent map + the + position-after-parent insert hold.""" + sc = _parent_solveconfig_stub() + sib = "P__realized_dispatch" + sc.spawn_realized_dispatch_sibling("P", sib) + + assert sc.realized_dispatch_parent[sib] == "P" # authoritative map + assert sc.model_solve["m"] == ["P", sib] # inserted after parent + assert sc.solve_modes[sib] == "single_solve" # SET + assert sc.realized_periods[sib] == sc.realized_periods["P"] + assert sc.timesets_used_by_solves[sib] == sc.timesets_used_by_solves["P"] + assert ( + sc.solve_period_years_represented[sib] + == sc.solve_period_years_represented["P"] + ) From 674fea78fdcaa468acc980143d14c37d21ef754e Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 11:44:28 +0300 Subject: [PATCH 74/87] feat(stochastic): de-scenario realized operational weight for separate_dispatch (W4d-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payoff of the W8→W4a→W4b→W4c arc. In a separate_dispatch stochastic hedge solve the OPERATIONAL branch weights now de-scenario realized: forecasts normalize among THEMSELVES (realized excluded from the denominator) and realized's own operational weight → 0 — safe because W4c's realized-dispatch sibling produces realized operations, so the hedge solve no longer needs realized weighted. The parent hedge objective becomes the expected FORECAST cost (+ invest annuity) instead of the realized-in-cohort artifact. within_solve (default) is UNCHANGED in this slice (realized→1.0 re-derivation is W4d-B, deferred). INVEST weight (pd_branch_weight) is UNTOUCHED (W7 owns invest). Decisive safety constraint: pd_branch_weight_lf (invest) and pdt_branch_weight_lf (operational) both read the shared solve_branch_weight.csv (realized forced 1.0), so the realized self-weight change lives ONLY in the OPERATIONAL producers — derive_solve_branch_weight, pd_branch_weight_lf, and the recourse NPV precondition (reads pd only) are literally untouched → the precondition passes unchanged (dissolving the old "realized=1.0→cohort 2.0→crash"). Both pdt producers patched in lock-step (semantically byte-equivalent, verified): - lazy _derived_branch.pdt_branch_weight_lf (the authoritative LP Param); - eager _emit_period_calc.emit_branch_weights pdt loop (pdt_branch_weight.csv). Rule: realized_set={d:(d,d)∈pb}; separate_dispatch → forecast d denom excludes realized, realized d → 0.0; within_solve/absent → legacy arm verbatim; deterministic pb-empty early-return untouched. Method channel (coherence directive — the parent's own flag on the standard per-solve solve_data channel, not a parallel sibling-detection): emit a one-row solve_data/dispatch_realization_method.csv at _native_run_model ~:854 from state.solve.dispatch_realization_method_for(complete_solve[solve]); both producers read it (absent → within_solve → byte-parity). The auto-spawned sibling drops the method → resolves within_solve → its realized ops are NOT zeroed (keeps obj 4,380,000). Realized branch at weight 0 is free/degenerate in the parent but harmless: not surfaced (sibling owns the realized window via last-wins) and does not corrupt invest (annuity 283,500 unchanged; forecasts drive capacity). Tests (FLEXTOOL_AUTOSCALE_STRICT=1): test_w4c_divergent_dispatch adds test_parent_objective_is_forecast_only_expectation (parent v_obj == 5,101,500 at rel=1e-9; negative control != 4,851,214.2857, the old realized-in-denominator value) and updates test_parent_forecasts_genuinely_diverge to assert only the forecast divergence {20,90} (the realized branch is now weight-0/degenerate, owned by the sibling). Sibling assertions kept. within_solve goldens byte-identical (gate is the per-solve marker, not forecast-existence). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_branch.py | 48 +++++++++++++++++ flextool/engine_polars/_emit_period_calc.py | 24 +++++++++ flextool/engine_polars/_emit_solve_writers.py | 26 +++++++++ flextool/engine_polars/_native_run_model.py | 9 ++++ .../test_w4c_divergent_dispatch.py | 53 +++++++++++++++++-- 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 37bb8a7e4..ed7a4011f 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -357,6 +357,34 @@ def steps_in_use_lf(workdir: Path | None, .unique()) +def dispatch_realization_method_read( + workdir: Path | None, + *, provider: "object | None" = None, +) -> str: + """Read the per-solve ``dispatch_realization_method`` marker (W4d-A). + + Returns the normalised lower-case mode (``"within_solve"`` / + ``"separate_dispatch"``). An absent / empty / unrecognised marker -> + ``"within_solve"`` (byte-identical prior behaviour), mirroring + :meth:`SolveConfig.dispatch_realization_method_for`. This is a scalar + config read over the standard ``solve_data`` provider channel (same + pattern as ``steps_in_use`` / ``period__branch`` / ``solve_branch_weight``); + it is NOT a quantity Param, so no autoscale registry entry is required. + """ + if workdir is None: + return "within_solve" + p = Path(workdir) / "solve_data" / "dispatch_realization_method.csv" + df = _provider_get(provider, p) + if df is None or df.height == 0 or not df.columns: + return "within_solve" + col = "method" if "method" in df.columns else df.columns[0] + raw = df[col][0] + if raw is None: + return "within_solve" + value = str(raw).strip().lower() + return value if value in ("within_solve", "separate_dispatch") else "within_solve" + + # --------------------------------------------------------------------------- # pd_branch_weight / pdt_branch_weight — lazy normalisation # --------------------------------------------------------------------------- @@ -502,6 +530,18 @@ def pdt_branch_weight_lf( w_lookup = {row["b"]: float(row["w"]) for row in bw.iter_rows(named=True) if row["w"] is not None} pb_set = {(row["d"], row["b"]) for row in pb.iter_rows(named=True)} + # W4d-A — under ``separate_dispatch`` the OPERATIONAL branch weights are + # de-scenario'd: forecasts normalise among THEMSELVES (the realized + # branch is excluded from the denominator) and the realized branch's own + # operational weight → 0 (its operations come from the W4c realized- + # dispatch sibling, so zeroing it in the hedge is safe). The marker is + # the PARENT solve's own flag; an absent marker → within_solve → the + # verbatim legacy arm below (byte-parity). ``realized_set`` is the + # self-anchored realized periods, the SAME ``(d, d) ∈ pb`` predicate + # ``derive_solve_branch_weight`` uses. + separate = dispatch_realization_method_read( + workdir, provider=provider) == "separate_dispatch" + realized_set = {d for d, b in pb_set if d == b} # branches_for_t[t] = {b : (b, t) ∈ dt} — derived from the dt set. # Mirrors flextool's ``branches_for_t`` from steps_in_use rows. branches_for_t: dict[str, set[str]] = {} @@ -517,6 +557,11 @@ def w(b: str) -> float: rows: list[tuple[str, str, float]] = [] pb_iter = list(pb.iter_rows(named=True)) for d, t in dt_pairs: + if separate and d in realized_set: + # Realized operational weight → 0 (bypass normalisation; the + # realized-dispatch sibling owns these operations). + rows.append((d, t, 0.0)) + continue branches_with_t = branches_for_t.get(t, set()) denom = 0.0 for row in pb_iter: @@ -525,6 +570,9 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue + if separate and b in realized_set: + # Exclude the realized self-branch from the forecast denom. + continue denom += w(b) if denom == 0.0: # Dense semantics — fall through to 1.0. diff --git a/flextool/engine_polars/_emit_period_calc.py b/flextool/engine_polars/_emit_period_calc.py index 2f598535d..7650ab27a 100644 --- a/flextool/engine_polars/_emit_period_calc.py +++ b/flextool/engine_polars/_emit_period_calc.py @@ -468,6 +468,23 @@ def w(b: str) -> float: period_in_use = _read_singles(solve_data_dir / "period_in_use_set.csv", provider=provider) + # W4d-A — the PARENT solve's resolved dispatch_realization_method marker + # (absent → within_solve → byte-parity). Under ``separate_dispatch`` the + # OPERATIONAL pdt weights are de-scenario'd (forecasts renormalise among + # themselves; the realized branch's own operational weight → 0). Kept in + # lock-step with ``_derived_branch.pdt_branch_weight_lf`` (the authoritative + # LP weight) so this emitted CSV stays byte-parity with the LP Param. The + # pd loop above is UNTOUCHED (invest weight, owned by W7). + method_df = _read_csv(solve_data_dir / "dispatch_realization_method.csv", + ["method"], provider=provider) + separate_dispatch = False + if method_df.height > 0: + raw = method_df["method"][0] + if raw is not None: + v = str(raw).strip().lower() + separate_dispatch = v == "separate_dispatch" + realized_set = {d for d, b in pb_rows if d == b} + pd_rows: list[tuple[str, float]] = [] for d in period_in_use: ts = first_time_for_d.get(d) @@ -489,6 +506,10 @@ def w(b: str) -> float: pdt_rows: list[tuple[str, str, float]] = [] for d, t in dt_pairs: + if separate_dispatch and d in realized_set: + # Realized operational weight → 0 (W4d-A; sibling owns these ops). + pdt_rows.append((d, t, 0.0)) + continue branches_with_t = branches_for_t.get(t, set()) denom = 0.0 for d2, b in pb_rows: @@ -496,6 +517,9 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue + if separate_dispatch and b in realized_set: + # Exclude the realized self-branch from the forecast denom. + continue denom += w(b) if denom == 0.0: continue diff --git a/flextool/engine_polars/_emit_solve_writers.py b/flextool/engine_polars/_emit_solve_writers.py index 7ed7086b7..581d57792 100644 --- a/flextool/engine_polars/_emit_solve_writers.py +++ b/flextool/engine_polars/_emit_solve_writers.py @@ -593,6 +593,32 @@ def emit_branch__period_relationship( derive_branch__period_relationship(period__branch)) +def emit_dispatch_realization_method( + method: str, + filename: str, + *, provider, +) -> None: + """Emit the per-solve ``dispatch_realization_method`` marker (W4d-A). + + A one-row ``(method,)`` frame recording the PARENT solve's resolved + ``solve.dispatch_realization_method`` (``within_solve`` / + ``separate_dispatch``). Co-located with the other per-solve weight + inputs (``solve_branch_weight.csv`` / ``period__branch.csv``) so the + operational ``pdt_branch_weight`` producers can read it through the + standard ``solve_data`` provider channel. + + The operational branch-weight producers consult this marker to decide + whether the realized operational weight is de-scenario'd + (``separate_dispatch``: forecasts renormalise among themselves and the + realized branch's own operational weight → 0, since its operations come + from the W4c realized-dispatch sibling) or left as the legacy + sibling-normalised weight (``within_solve``). An ABSENT marker is read + as ``within_solve`` → byte-identical prior behaviour. + """ + _emit_path(provider, filename, + _to_utf8_frame(("method",), [(method,)])) + + def derive_solve_branch_weight( complete_solve: str, active_time_list: dict[str, list[tuple[str, ...]]], diff --git a/flextool/engine_polars/_native_run_model.py b/flextool/engine_polars/_native_run_model.py index 1840391bc..9248c53bf 100644 --- a/flextool/engine_polars/_native_run_model.py +++ b/flextool/engine_polars/_native_run_model.py @@ -851,6 +851,15 @@ def _mem_cp(label: str, user_label: str, str(wf / "solve_data/period__branch.csv"), provider=sub_solve_provider, ) + # W4d-A — record the PARENT solve's resolved + # dispatch_realization_method so the operational pdt_branch_weight + # producers can de-scenario the realized operational weight under + # ``separate_dispatch`` (absent marker → within_solve → byte-parity). + solve_writers.emit_dispatch_realization_method( + state.solve.dispatch_realization_method_for(complete_solve[solve]), + str(wf / "solve_data/dispatch_realization_method.csv"), + provider=sub_solve_provider, + ) solve_writers.emit_branch_weights_and_map( complete_solve[solve], active_time_lists[solve], diff --git a/tests/engine_polars/test_w4c_divergent_dispatch.py b/tests/engine_polars/test_w4c_divergent_dispatch.py index 53ee98b8a..fd0b41e8a 100644 --- a/tests/engine_polars/test_w4c_divergent_dispatch.py +++ b/tests/engine_polars/test_w4c_divergent_dispatch.py @@ -190,10 +190,18 @@ def test_sibling_objective_hand_calc(chain) -> None: @pytest.mark.solver def test_parent_forecasts_genuinely_diverge(chain) -> None: - """The parent is genuinely stochastic and its branches diverge: peaker - hits 50 (rlz), 20 (high) and 90 (low) MW — proving the realized and - forecast data are DISTINCT, so the sibling's 50 MW is a real - realized-branch projection, not an accidental shared value.""" + """The parent's FORECAST branches genuinely diverge: peaker hits 20 + (high) and 90 (low) MW — proving the forecast data are DISTINCT. + + W4d-A note: under ``separate_dispatch`` the parent hedge's REALIZED + operational branch now carries OPERATIONAL weight 0 (its operations are + owned by the W4c realized-dispatch sibling). A weight-0 branch is + DEGENERATE in the LP objective — the realized peaker is free in [0, 90] + and the solver lands it anywhere (empirically 0 here, not the old 50) — + so we no longer assert the realized 50 MW level on the parent. The + realized dispatch is pinned on the SIBLING instead + (``test_sibling_reads_realized_branch_not_forecast``). We assert ONLY + the forecast divergence {20, 90}.""" steps, _wf = chain parent = _step(steps, PARENT) assert parent.solution is not None and parent.optimal @@ -202,12 +210,47 @@ def test_parent_forecasts_genuinely_diverge(chain) -> None: round(v, 6) for v in flows.filter(pl.col("source") == "peaker")["value"].to_list() ) - for expected in (PEAKER_MW, PEAKER_HIGH_MW, PEAKER_LOW_MW): + for expected in (PEAKER_HIGH_MW, PEAKER_LOW_MW): assert any(v == pytest.approx(expected) for v in peaker_vals), ( f"parent peaker levels {sorted(peaker_vals)} missing {expected}" ) +# W4d-A hand-calc: the parent hedge objective is the FORECAST-only +# expectation (realized operational weight 0) + the invest annuity. +# high forecast: 20 MW * 8760 h * 10 CUR/MWh = 1 752 000 +# low forecast: 90 MW * 8760 h * 10 CUR/MWh = 7 884 000 +# expectation : 0.5 * 1 752 000 + 0.5 * 7 884 000 = 4 818 000 +# + invest annuity = 283 500 +# = 5 101 500 +PARENT_FORECAST_ONLY_OBJECTIVE = 5_101_500.0 +# Negative control: the OLD value when the realized branch was kept in the +# normalisation denominator (realized self-weight 1.0 in the cohort). +PARENT_REALIZED_IN_DENOM_OBJECTIVE = 4_851_214.2857 + + +@pytest.mark.solver +def test_parent_objective_is_forecast_only_expectation(chain) -> None: + """W4d-A: under ``separate_dispatch`` the parent hedge's OPERATIONAL + branch weights are de-scenario'd — forecasts normalise among themselves + (realized excluded from the denominator) and the realized branch's own + operational weight → 0. The parent objective is therefore the + FORECAST-ONLY expectation (0.5/0.5 of the 20 MW / 90 MW forecasts = + 4 818 000) plus the invest annuity (283 500) = 5 101 500. + + Negative control: 4 851 214.2857 — the OLD objective with the realized + branch kept in the normalisation denominator. INVEST weight + (``pd_branch_weight``) is UNTOUCHED, so the annuity term is unchanged.""" + steps, wf = chain + _step(steps, PARENT) # ensure the parent step exists + obj = float( + pl.read_parquet(wf / "output_raw" / f"v_obj__{PARENT}.parquet") + ["objective"][0] + ) + assert obj == pytest.approx(PARENT_FORECAST_ONLY_OBJECTIVE, rel=1e-9) + assert obj != pytest.approx(PARENT_REALIZED_IN_DENOM_OBJECTIVE, rel=1e-6) + + @pytest.mark.solver def test_realized_window_owned_by_sibling(chain) -> None: """Last-wins: the parent's realized p2035 window is emptied; the From 14d3c7a790406b72c469b53f527ea37c34d49741 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 18:01:47 +0300 Subject: [PATCH 75/87] feat(stochastic): invert W1 branch-reveal validation to forecasts-only (R1) The stochastic fan is forecasts-only; "realized" is an optional 0-or-1 designation (realized_yn=="yes"), not a mandatory weighted member. Invert the W1 triggering-cohort validation accordingly: - reject 2+ realized (was: reject != exactly one realized) - reject cohorts with zero forecast (realized:no, weight>0) members - accept n_real in {0,1} with >=1 forecast - Block B population guard: found>1 raises; found==0 appends nothing - keep the period/roll-boundary reveal admission check unchanged Validation-only: no weight-math, fan-out exclusion, or precondition code touched (those re-ground in R2/R4/W7). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_stochastic.py | 104 +++++++++++++----- ..._create_stochastic_periods_continuation.py | 32 +++++- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index a4b026c66..d896b0b27 100644 --- a/flextool/engine_polars/_stochastic.py +++ b/flextool/engine_polars/_stochastic.py @@ -40,9 +40,15 @@ (``(period, period)`` self-row plus one row per fan member, realized and zero-weight included). -* **Single realized branch per period**: each period must have - exactly one ``realized: yes`` row (or zero before any branching has - occurred). Found 0 or > 1 → raise ``FlexToolConfigError``. +* **Forecasts-only fan with optional realized designation**: a + stochastic fan is FORECASTS ONLY (probability-weighted, Σ=1, no + realized member). "Realized" is an OPTIONAL 0-or-1 designation per + revealing cohort, keyed off the ``realized_yn == "yes"`` column — it + feeds only the realization region / separate realized-dispatch solve + and is NEVER a weighted fan member. A revealing cohort must carry + ≥1 forecast branch (``realized: no`` with nonzero weight); 0 or 1 + ``realized: yes`` rows are both valid; 2+ realized rows → raise + ``FlexToolConfigError``. """ from __future__ import annotations @@ -415,11 +421,14 @@ def create_stochastic_periods( Raises: FlexToolConfigError: When the reveal that triggers a solve does not start at its branching period's first step (not a - period-boundary reveal), when that triggering cohort does - not carry exactly one ``realized: yes`` row, or when a - single (non-rolling) solve declares ``stochastic_branches`` - none of which reveal at a reachable step of its active time - (silently-ignored branches). + period-boundary reveal), when that triggering cohort carries + more than one ``realized: yes`` row (0 or 1 is valid — the + fan is forecasts only, realized is an optional designation), + when that cohort carries no forecast branch (``realized: no`` + with nonzero weight), or when a single (non-rolling) solve + declares ``stochastic_branches`` none of which reveal at a + reachable step of its active time (silently-ignored + branches). """ period__branch_lists: defaultdict[str, list] = defaultdict(list) solve_branch__time_branch_lists: defaultdict[str, list] = defaultdict(list) @@ -466,7 +475,11 @@ def create_stochastic_periods( # fan-at-first (and does NOT re-introduce Option-B's # "first period can't branch" guard — see Appendix). # (ii) the triggering cohort {rows sharing that (period, - # start_step)} must carry EXACTLY ONE realized:yes row. + # start_step)} must carry AT MOST ONE realized:yes row + # (0 or 1 — the fan is forecasts only; realized is an + # optional designation) AND AT LEAST ONE forecast branch + # (realized:no with nonzero weight); 2+ realized or 0 + # forecasts → raise. # When no reveal is reachable in this solve's window the solve # stays a deterministic trunk (``branched`` remains False, so # ``branch_start_time_lists[solve]`` stays None and the walk @@ -491,11 +504,12 @@ def create_stochastic_periods( # documented limitation for the rolling case. For a SINGLE # (non-rolling) solve the WHOLE reveal picture is visible in the # one window, so every declared reveal is validated strictly — - # each must sit at its period's first step and each branching - # period must carry exactly one realized:yes — restoring the - # pre-W1 per-row strictness (a mid-period or extra reveal in a - # single solve is a real authoring error, not another roll's - # reveal). This raises, matching the pre-W1 behaviour. + # each must sit at its period's first step, each branching + # period must carry at most one realized:yes (0 or 1) and at + # least one forecast branch (realized:no, nonzero weight) — + # restoring the pre-W1 per-row strictness (a mid-period or extra + # reveal in a single solve is a real authoring error, not another + # roll's reveal). This raises, matching the pre-W1 behaviour. # ---------------------------------------------------------- is_rolling_sub_solve = ( getattr( @@ -549,13 +563,33 @@ def create_stochastic_periods( for r in info if r[0] == trig_period and r[2] == trig_step and r[3] == "yes" ) - if n_real != 1: + n_forecast = sum( + 1 + for r in info + if r[0] == trig_period + and r[2] == trig_step + and r[3] != "yes" + and float(r[4]) != 0.0 + ) + if n_real > 1: message = ( "The triggering stochastic branching cohort (period " f"{trig_period!r}, start_step {trig_step!r}) must have " - f"exactly one realized:yes branch; found {n_real}. " - "Check stochastic_branches (one realized branch per " - "reveal; start_step at the period's first step)." + f"at most one realized:yes branch per revealing cohort; " + f"found {n_real}. Check stochastic_branches (the fan is " + "forecasts only; realized is an optional 0-or-1 " + "designation; start_step at the period's first step)." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + if n_forecast < 1: + message = ( + "The triggering stochastic branching cohort (period " + f"{trig_period!r}, start_step {trig_step!r}) must " + "contain at least one forecast (non-realized, " + f"nonzero-weight) branch; found {n_forecast}. A " + "stochastic fan is forecasts only — realized is an " + "optional designation. Check stochastic_branches." ) self.logger.error(message) raise FlexToolConfigError(message) @@ -586,11 +620,29 @@ def create_stochastic_periods( 1 for r in info if r[0] == period_k and r[3] == "yes" ) - if n_real_k != 1: + n_forecast_k = sum( + 1 for r in info + if r[0] == period_k + and r[3] != "yes" + and float(r[4]) != 0.0 + ) + if n_real_k > 1: + message = ( + f"Stochastic branching period {period_k!r} " + "must have at most one realized:yes branch per " + f"revealing cohort; found {n_real_k}. Check " + "stochastic_branches." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + if n_forecast_k < 1: message = ( f"Stochastic branching period {period_k!r} " - "must have exactly one realized:yes branch; " - f"found {n_real_k}. Check stochastic_branches." + "must contain at least one forecast " + "(non-realized, nonzero-weight) branch; found " + f"{n_forecast_k}. A stochastic fan is forecasts " + "only — realized is an optional designation. " + "Check stochastic_branches." ) self.logger.error(message) raise FlexToolConfigError(message) @@ -703,9 +755,9 @@ def create_stochastic_periods( if period in fix_storage_time_list: new_fix_storage_time_list[period] = fix_storage_time_list[period] - # Find the realized branch for each period. Each period - # must have exactly one realized: yes row (or zero before - # any branching has occurred). + # Find the realized branch for each period. Realized is an + # optional 0-or-1 designation: a period may have no realized + # row (forecasts-only cohort) or exactly one; 2+ → raise. for period, active_time in active_time_list.items(): found = 0 # Before branching: row[0]==period, row[2]==first step, @@ -730,9 +782,9 @@ def create_stochastic_periods( solve_branch__time_branch_lists[solve].append( (period, row[1]) ) - if (branch_start_time_lists[solve] is not None and found == 0) or found > 1: + if found > 1: message = ( - "Each period should have one and only one realized branch. " + "Each period should have at most one realized branch. " "Found: " + str(found) + "\n" ) self.logger.error(message) diff --git a/tests/engine_polars/test_create_stochastic_periods_continuation.py b/tests/engine_polars/test_create_stochastic_periods_continuation.py index d032a45ec..e86c94984 100644 --- a/tests/engine_polars/test_create_stochastic_periods_continuation.py +++ b/tests/engine_polars/test_create_stochastic_periods_continuation.py @@ -297,18 +297,42 @@ def test_w1_trigger_cohort_two_realized_raises() -> None: ("p1", "rlz2", "t0001", "yes", "1"), ("p1", "low", "t0001", "no", "3"), ] - with pytest.raises(FlexToolConfigError, match="exactly one realized"): + with pytest.raises(FlexToolConfigError, match="at most one"): _run_one(solver, info, {"p1": ROLL_WIN}) -def test_w1_trigger_cohort_zero_realized_raises() -> None: - """Reject: the triggering cohort carries no realized:yes row.""" +def test_w1_trigger_cohort_zero_realized_fans_out() -> None: + """Accept: the fan is forecasts only — a cohort with zero realized:yes + rows (two forecasts) is VALID and fans out into both forecasts (no + realized self-row appended).""" solver = _make_solver_with_modes({}) info = [ ("p1", "low", "t0001", "no", "3"), ("p1", "high", "t0001", "no", "3"), ] - with pytest.raises(FlexToolConfigError, match="exactly one realized"): + (pb, sbtb, atl, jumps, fstl, rtl, bstl) = _run_one( + solver, info, {"p1": ROLL_WIN} + ) + assert bstl["s"] == ("p1", "t0001") + assert pb["s"] == [ + ("p1", "p1"), + ("p1", "p1_low"), + ("p1", "p1_high"), + ] + # Both forecasts receive active time; no realized self-row (n_real=0). + assert sbtb["s"] == [("p1_low", "low"), ("p1_high", "high")] + assert list(atl["s"].keys()) == ["p1", "p1_low", "p1_high"] + + +def test_w1_trigger_cohort_zero_forecast_raises() -> None: + """Reject: a cohort with only a realized member (no forecast branch — + realized:no with nonzero weight) violates the forecasts-only rule.""" + solver = _make_solver_with_modes({}) + info = [ + ("p1", "rlz", "t0001", "yes", "1"), + ("p1", "dead", "t0001", "no", "0"), + ] + with pytest.raises(FlexToolConfigError, match="at least one forecast"): _run_one(solver, info, {"p1": ROLL_WIN}) From 91d1a6eed91641f177ebeb8006658ca7473ecd28 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 7 Oct 2026 19:45:39 +0300 Subject: [PATCH 76/87] feat(stochastic): forecast-to-forecast non-anticipativity (R-NA/R-CONT) Re-ground non-anticipativity to the corrected model: the stochastic fan is forecasts only, so NA ties forecasts TO EACH OTHER via a deterministic per-anchor-group reference-forecast star, never to the realized trunk. Works at n_real=0. model.py: in the db_pairs build (behind the existing height>0 gate), group raw db_pairs by anchor trunk d; per group pick d_ref = lexically smallest forecast token, build the star {(d_ref, b!=d_ref)}, and re-key dt_non_anticipativity's anchor d -> d_ref (same t). Both frames must be re-keyed: the five NA families inner-join db_pairs AND dtna on d, so a db_pairs-only re-point emits zero constraints. Pass the re-keyed dtna via a new dtna_override kwarg to _add_non_anticipativity_constraints. The deterministic (no-fan) path stays byte-identical (empty raw db_pairs -> no groups -> no call). No new var/cstr/param/autoscale name. R-CONT: non_anticipativity_storage_state_first is load-bearing (proven) and re-points for free via the shared re-grounded frames; kept, not deleted (deletion deferred to W8b behind a float==0 guard). Docstrings updated: ties are forecast-to-forecast, not to the realised trunk. Tests: re-point every trunk-anchored NA assertion to forecast-to-forecast (trunk excluded from over.d/over.b; forecasts tied equal) across test_rolling_stochastic_na, test_recourse_invest_na, test_configurable_nonanticipativity, test_non_anticipativity, test_flex_2day_stochastic_dispatch_full_storage, and the two emission tests; add a new n_real=0 fixture (build_stoch_two_period_nreal0) + test witnessing the forecast star with no realized member. Re-ground the 2_day_stochastic_dispatch scenario golden + pinned objective (269.406895209 -> 268.081475165; a relaxation, new <= old, verified). Also completes R1 validation-test fallout in test_recourse_invest_hedge (corrected-model messages; zero-realized cohorts with >=1 forecast now accepted). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/model.py | 159 +- tests/conftest.py | 27 + .../emission/test_na_storage_continuation.py | 81 +- ...st_non_anticipativity_storage_use_emits.py | 40 +- .../test_configurable_nonanticipativity.py | 37 +- ...x_2day_stochastic_dispatch_full_storage.py | 25 +- .../test_nonanticipativity_nreal0.py | 180 + .../test_recourse_invest_hedge.py | 89 +- .../engine_polars/test_recourse_invest_na.py | 57 +- .../test_rolling_stochastic_na.py | 62 +- .../2_day_stochastic_dispatch/costs__dt.csv | 124 +- .../summary_solve.csv | 16 +- .../fixtures/build_stoch_two_period_nreal0.py | 118 + tests/fixtures/stoch_two_period_nreal0.json | 6480 +++++++++++++++++ tests/scenarios.yaml | 7 +- tests/test_non_anticipativity.py | 84 +- 16 files changed, 7358 insertions(+), 228 deletions(-) create mode 100644 tests/engine_polars/test_nonanticipativity_nreal0.py create mode 100644 tests/fixtures/build_stoch_two_period_nreal0.py create mode 100644 tests/fixtures/stoch_two_period_nreal0.json diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 2efafac97..c3832e45d 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -182,13 +182,26 @@ def _add_non_anticipativity_constraints( m, d, db_pairs: pl.DataFrame, *, v_state, v_online_integer, v_online_linear, v_reserve, v_flow, has_minload_eff, + dtna_override: pl.DataFrame | None = None, ) -> None: """Emit the four non-anticipativity constraint families (mod:4173-4233). All four constraints pin the per-branch dispatch variable at the - anchor period ``d`` equal to the same variable at sibling branch - ``b`` for every (d, t) ∈ ``dt_non_anticipativity``. The cohort - ``db_pairs`` is the (d, b) frame with d != b and b ∈ period_in_use. + reference-forecast anchor ``d`` equal to the same variable at sibling + FORECAST ``b`` for every (d, t) ∈ the re-grounded tie set. The cohort + ``db_pairs`` is a FORECAST-TO-FORECAST star: ``d`` is a deterministic + reference forecast and ``b`` ranges over the OTHER forecasts of the same + anchor group. The tie therefore couples the forecast branches to EACH + OTHER (via the reference forecast), NEVER to the realised period / trunk + — that is what makes the committed decision non-anticipative, and it + holds with no realised branch at all (``n_real`` may be 0). + + The caller (the ``db_pairs`` build block) re-grounds both ``db_pairs`` + (→ the forecast star) and ``dt_non_anticipativity`` (anchor ``d`` re-keyed + from the trunk to the reference forecast ``d_ref`` per anchor group, + keeping the same ``t``) and hands the re-keyed tie set in via + ``dtna_override``. Every family here joins ``db_pairs`` (cross) then the + tie set on ``"d"``, so both re-keys are required for a non-empty join. Implementation strategy (d-packed): LHS = var[..., d, t] @@ -212,13 +225,15 @@ def _add_non_anticipativity_constraints( ~150 LOC of LHS reconstruction into 4 LOC and matches the .mod's LP-equivalent feasible region (verified via parity). """ - dtna = d.dt_non_anticipativity + dtna = dtna_override if dtna_override is not None else d.dt_non_anticipativity if dtna is None or dtna.height == 0: return # 1. non_anticipativity_storage_use — pin the per-(n, t) NET CHARGE - # LHS across siblings (mod:4173-4217). Domain: nodes in a - # stochastic-group; (d, b) ∈ db_pairs; (d, t) ∈ dt_non_anticipativity. + # LHS across FORECASTS (mod:4173-4217). Domain: nodes in a + # stochastic-group; (d, b) ∈ db_pairs (a forecast-to-forecast star + # keyed on the reference forecast d_ref — NOT the realised trunk); + # (d, t) ∈ the re-grounded tie set. # # LHS: # + sink-side flow contributions (sink == n) @@ -355,27 +370,31 @@ def _add_non_anticipativity_constraints( # 1b. non_anticipativity_storage_state_first — delivers §D.6 # branch-point continuity by pinning ABSOLUTE ``v_state`` at the - # FIRST tied step of each anchor period ``d``: + # FIRST tied step of each anchor ``d`` (the reference forecast + # d_ref, NOT the realised trunk): # - # v_state[n, d, t_first] − v_state[n, b, t_first] == 0 + # v_state[n, d_ref, t_first] − v_state[n, b, t_first] == 0 # # The storage net-charge family above ties only the per-(n, t) net # charge (the consecutive ``v_state`` delta), so across a contiguous - # tied jump it fixes every branch's state SHAPE but leaves a free - # constant offset between the realized trunk and each forecast copy. - # In single-solve the trunk is hard start-bound and every branch is - # reference-value end-bound, so the offset telescopes to zero on its - # own; in rolling the untied post-reveal tail breaks that telescoping - # and the forecast copies float by a constant (~0.06 in + # tied jump it fixes every forecast's state SHAPE but leaves a free + # constant offset between the reference forecast and each other + # forecast copy. In single-solve each forecast is hard start-bound + # and reference-value end-bound, so the offset telescopes to zero on + # its own; in rolling the untied post-reveal tail breaks that + # telescoping and the forecast copies float by a constant (~0.06 in # ``1_week_rolling_wind``). Pinning absolute ``v_state`` at the first # tied step restores the anchor: combined with the per-step net-charge # ties it closes the whole contiguous jump absolutely. # - # This tie is OPTIMUM-CONDITIONAL, not a hard structural anchor: it - # holds the branches equal to EACH OTHER at the branch point, but - # interior drift away from the shared level stays feasible whenever - # balance slack (``vq_state_up`` / ``vq_state_down``) absorbs it or - # the inflow differs across branches at the tied steps — the + # This tie is LOAD-BEARING and is KEPT (it re-points for free onto the + # forecast star via the shared re-grounded db_pairs / tie set — it is + # a forecast-to-forecast coupling, never a tie to the realised + # period/trunk). It is OPTIMUM-CONDITIONAL, not a hard structural + # anchor: it holds the forecasts equal to EACH OTHER at the branch + # point, but interior drift away from the shared level stays feasible + # whenever balance slack (``vq_state_up`` / ``vq_state_down``) absorbs + # it or the inflow differs across forecasts at the tied steps — the # net-charge family controls neither. For the deterministic-inflow, # zero-self-discharge reservoirs this fixture exercises, the drift is # non-optimal and the tie binds. ``v_online`` / ``v_reserve`` need no @@ -4617,22 +4636,28 @@ def _wt(param): _build_prof("before:non_anticipativity") # ─── Non-anticipativity constraints (A6) ───────────────────────────── # mod:4173-4233. Four constraint families pin per-branch dispatch - # variables to the realised period at every (d, t) ∈ dt_non_anticipativity: + # variables FORECAST-TO-FORECAST at every (d, t) ∈ the re-grounded tie + # set — the committed decision is the same across the forecast branches, + # which IS its non-anticipative realisation. The tie is NEVER anchored + # to the realised period / trunk (there may be no realised branch at all; + # ``n_real`` can be 0): # # non_anticipativity_storage_use (n, d, b, t) — net storage charge # LHS at (d, t) equals LHS at (b, t). Active only when at least # one (g, n) ∈ group_node has g ∈ groupStochastic. # non_anticipativity_online_integer (p, d, b, t) — v_online_integer - # equal across siblings. + # equal across forecasts. # non_anticipativity_online_linear (p, d, b, t) — v_online_linear - # equal across siblings. + # equal across forecasts. # non_anticipativity_reserve (p, r, ud, n, d, b, t) — v_reserve - # equal across siblings. + # equal across forecasts. # - # Domain: (d, b) ∈ period__branch with d != b and b ∈ period_in_use. - # The b == d self-loop is vacuous and the metadata-only branches not - # in period_in_use (e.g. ``period1_realized``) must be excluded so - # the constraint references a real LP variable. + # Raw cohort: (d, b) ∈ period__branch with d != b and b ∈ period_in_use. + # The b == d self-loop is vacuous and the metadata-only branches not in + # period_in_use (e.g. ``period1_realized``) are excluded so the + # constraint references a real LP variable. ``d`` here is the trunk + # (the real period name); the re-ground below turns this into a + # forecast-to-forecast star keyed on a reference forecast. if (d.dt_non_anticipativity is not None and d.dt_non_anticipativity.height > 0 and d.period_branch_full is not None @@ -4663,15 +4688,79 @@ def _wt(param): cast_dim(pl.col("b"), None, "b"), ) if db_pairs.height > 0: - _add_non_anticipativity_constraints( - m, d, db_pairs, - v_state = locals().get("v_state") if has_storage else None, - v_online_integer = locals().get("v_online_int") if has_online_int else None, - v_online_linear = locals().get("v_online_lin") if has_online_lin else None, - v_reserve = reserve_vars.get("v_reserve") if reserve_vars else None, - v_flow = locals().get("v_flow") if has_proc else None, - has_minload_eff = has_minload_eff, + # ── Forecast-to-forecast re-ground (per anchor group) ── + # The raw cohort anchors every forecast ``b`` to the trunk ``d`` + # (the realised/base period). Re-ground it to a FORECAST star so + # the tie couples the forecast branches to EACH OTHER, never to + # the trunk (corrected stochastic model: the fan is forecasts + # only; realisation of a committed variable IS the cross-forecast + # tie; works at n_real=0). + # + # PER ANCHOR GROUP (NOT a single global reference): group the raw + # pairs by trunk ``d``; within each group take the forecasts + # (``b`` tokens), pick the lexicographically smallest as the + # reference forecast ``d_ref`` (LP-deterministic — a lexical sort + # of stable Utf8 tokens, same invariant as add_var/add_cstr domain + # sorting), and build the star {(d_ref, b) : b ∈ forecasts, b ≠ + # d_ref}. A group with <2 forecasts is skipped (the tie is + # vacuous with a single forecast). A SINGLE GLOBAL d_ref would be + # WRONG: it would cross-couple distinct anchor periods at + # mismatched timesteps. + # + # BOTH frames are re-keyed: ``db_pairs`` → the forecast star, and + # ``dt_non_anticipativity`` → anchor ``d`` mapped from the trunk + # to that group's ``d_ref`` (same ``t``). The families inner-join + # the tie set on ``"d"``; re-keying db_pairs alone would leave the + # tie set's ``d`` on the trunk and emit ZERO constraints. + # + # Realised exclusion: realisation is currently the self-row trunk, + # already dropped from ``b`` by ``filter(d != b)`` above — so no + # extra filter is needed here. Should a future realised- + # designated non-self in-use branch appear, drop it from the + # forecast set at this point (by the realised marker R1 settles). + dtna_raw = d.dt_non_anticipativity + _raw_db = db_pairs.with_columns( + pl.col("d").cast(pl.Utf8), pl.col("b").cast(pl.Utf8), ) + _dtna_u = dtna_raw.with_columns(pl.col("d").cast(pl.Utf8)) + _stars: list[pl.DataFrame] = [] + _dtnas: list[pl.DataFrame] = [] + for d_trunk in sorted( + _raw_db.select("d").unique().to_series().to_list() + ): + forecasts = sorted( + _raw_db.filter(pl.col("d") == d_trunk) + .select("b").unique().to_series().to_list() + ) + if len(forecasts) < 2: + continue + d_ref = forecasts[0] + _stars.append(pl.DataFrame({ + "d": [d_ref] * (len(forecasts) - 1), + "b": [b for b in forecasts if b != d_ref], + })) + _dtnas.append( + _dtna_u.filter(pl.col("d") == d_trunk) + .with_columns(pl.lit(d_ref).alias("d")) + ) + if _stars: + db_pairs_reground = pl.concat(_stars).with_columns( + cast_dim(pl.col("d"), None, "d"), + cast_dim(pl.col("b"), None, "b"), + ) + dtna_reground = pl.concat(_dtnas).with_columns( + cast_dim(pl.col("d"), None, "d"), + ) + _add_non_anticipativity_constraints( + m, d, db_pairs_reground, + v_state = locals().get("v_state") if has_storage else None, + v_online_integer = locals().get("v_online_int") if has_online_int else None, + v_online_linear = locals().get("v_online_lin") if has_online_lin else None, + v_reserve = reserve_vars.get("v_reserve") if reserve_vars else None, + v_flow = locals().get("v_flow") if has_proc else None, + has_minload_eff = has_minload_eff, + dtna_override = dtna_reground, + ) # ─── Investment non-anticipativity (v72 — the standard two-stage # stochastic-investment mode) ──────────────────────────────────────── diff --git a/tests/conftest.py b/tests/conftest.py index b3014a403..0faf20766 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -581,6 +581,30 @@ def stoch_two_period_free_ops_db_url( return url +@pytest.fixture(scope="session") +def stoch_two_period_nreal0_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_nreal0`` fixture DB — n_real=0 forecast-to-forecast + non-anticipativity witness (R-NA re-grounding). + + Built from ``tests/fixtures/stoch_two_period_nreal0.json`` (generated by + ``tests/fixtures/build_stoch_two_period_nreal0.py``): the Slice G + ``free_ops`` topology with all three branches as FORECASTS + (``realized: no``, nonzero weight ⟹ ``n_real = 0``) and a curated + ``shared_operation_periods = [p2035, p2040]`` window. Exercises the + forecast star re-ground with NO realised/trunk anchor. Defensive + migration as in :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stpn0") + / "stoch_two_period_nreal0.sqlite") + url = json_to_db(FIXTURES_DIR / "stoch_two_period_nreal0.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -679,6 +703,7 @@ def examples_db_url(examples_db_path: Path) -> str: "stoch_two_period_invest_na": "stoch_two_period_invest_na_db_url", "stoch_two_period_hedge": "stoch_two_period_hedge_db_url", "stoch_two_period_free_ops": "stoch_two_period_free_ops_db_url", + "stoch_two_period_nreal0": "stoch_two_period_nreal0_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -725,6 +750,7 @@ def scenario_workdir( stoch_two_period_invest_na_db_url, stoch_two_period_hedge_db_url, stoch_two_period_free_ops_db_url, + stoch_two_period_nreal0_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -784,6 +810,7 @@ def test_foo(scenario_workdir): "stoch_two_period_invest_na": stoch_two_period_invest_na_db_url, "stoch_two_period_hedge": stoch_two_period_hedge_db_url, "stoch_two_period_free_ops": stoch_two_period_free_ops_db_url, + "stoch_two_period_nreal0": stoch_two_period_nreal0_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/engine_polars/emission/test_na_storage_continuation.py b/tests/engine_polars/emission/test_na_storage_continuation.py index e1c47ac19..adbbae339 100644 --- a/tests/engine_polars/emission/test_na_storage_continuation.py +++ b/tests/engine_polars/emission/test_na_storage_continuation.py @@ -12,19 +12,32 @@ pattern); no hand calculation is needed — this is a structural constraint-domain test. -Post-fix, ``period_branch_full`` gains the continuation pairs and -``period_in_use`` gains ``p2040_low``, so ``db_pairs`` -(``model.py`` NA dispatch: ``d != b``, ``b ∈ period_in_use``) = -{(p2035, p2035_low), (p2040, p2040_low)} and the constraint domain -joins ``dt_non_anticipativity`` on ``d``: +Forecast-to-forecast ground: the base ``stoch_two_period`` fixture has a +SINGLE forecast (``low``) per reveal, which under the corrected model makes +non-anticipativity vacuous (a single forecast has nothing to tie). This +structural continuation test therefore adds a SECOND forecast (``high``, +``realized: no``) to its in-memory variant so the NA family fires +forecast-to-forecast and the continuation composition can still be +witnessed. (This is a self-contained addition to the test's own derived +variant; the committed hand-calc fixtures the degenerate-fixture slice R3 +targets are untouched.) - 1 stoch node x [(p2035, p2035_low) x 3 t + (p2040, p2040_low) x 3 t] +Post-fix, ``period_branch_full`` gains the continuation pairs and +``period_in_use`` gains ``p2035_high`` / ``p2040_low`` / ``p2040_high``. The +raw ``db_pairs`` (``model.py`` NA dispatch: ``d != b``, ``b ∈ +period_in_use``) anchors every forecast to the trunk; the re-ground turns +each anchor group into a FORECAST star keyed on the lexicographically +smallest forecast (``p2035_high`` / ``p2040_high`` — ``high`` < ``low``), +tying ``low`` to it: + + 1 stoch node x [(p2035_high, p2035_low) x 3 t + + (p2040_high, p2040_low) x 3 t] = 6 LP rows. DEFECT PIN: pre-fix the continuation cohort had no active time, so -``period_in_use`` lacked ``p2040_low``, ``dt_non_anticipativity`` had -no p2040 rows, and the constraint collapsed to the branching-period -rows only (3), with no (p2040, p2040_low, t) domain row at all. +``period_in_use`` lacked the p2040 forecast copies, ``dt_non_anticipativity`` +had no p2040 rows, and the constraint collapsed to the branching-period +rows only (3), with no p2040 continuation domain row at all. """ from __future__ import annotations @@ -55,6 +68,11 @@ def _val(v: "str | float") -> list: ] +def _nested(pairs): + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + @pytest.fixture(scope="module") def storage_variant_workdir(tmp_path_factory, test_solver_config_dir): """Build + cascade-run the storage-bearing variant's ``stoch`` @@ -93,6 +111,32 @@ def storage_variant_workdir(tmp_path_factory, test_solver_config_dir): ["unit", "hydro", "efficiency", _val(1.0), "base"], ] + # Forecast-to-forecast: add a SECOND forecast ``high`` (realized: no) + # alongside ``low`` so each reveal has >=2 forecasts and the NA family + # fires (a single forecast would be vacuous under the corrected model). + # ``rlz`` stays the realised designation (keeps the trunk period name). + branches_2fc = { + "index_type": "str", + "data": [[ + "p2035", + _nested([ + ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 1.5)]))])), + ("high", _nested([("t0001", _nested([("no", 1.5)]))])), + ]), + ]], + } + replaced = False + for row in spec["parameter_values"]: + if (row[0] == "solve" and row[2] == "stochastic_branches"): + row[3] = [ + base64.b64encode(json.dumps(branches_2fc).encode()) + .decode("ascii"), + "map", + ] + replaced = True + assert replaced, "stoch_two_period.json has no stochastic_branches row" + root = tmp_path_factory.mktemp("_root_na_storage_continuation") json_path = root / "stoch_two_period_storage.json" json_path.write_text(json.dumps(spec)) @@ -131,14 +175,17 @@ def test_na_storage_use_composes_with_continuation_cohorts( assert data.groupStochastic is not None assert data.period_in_use_set is not None piu = set(str(v) for v in data.period_in_use_set["d"].to_list()) - assert {"p2035", "p2035_low", "p2040", "p2040_low"} <= piu + assert {"p2035", "p2035_low", "p2035_high", + "p2040", "p2040_low", "p2040_high"} <= piu # dtna == the 6 realized (d, t) rows. assert data.dt_non_anticipativity.height == 6 - # Domain: 1 stoch node x [(p2035, p2035_low) x 3 + (p2040, - # p2040_low) x 3] = 6 rows (DEFECT PIN — pre-fix: 3, no p2040 - # continuation pair). + # Domain: forecast-to-forecast star keyed on d_ref=p2035_high / + # p2040_high (lexicographically smallest forecast; high < low), tying + # low to it. 1 stoch node x [(p2035_high, p2035_low) x 3 + (p2040_high, + # p2040_low) x 3] = 6 rows (DEFECT PIN — pre-fix the p2040 continuation + # pair was absent, collapsing to 3; the trunk p2035/p2040 is NOT a hub). assert_cstr_row_count(pb, "non_anticipativity_storage_use", 6) recs = pb.cstrs_named("non_anticipativity_storage_use") @@ -150,11 +197,15 @@ def test_na_storage_use_composes_with_continuation_cohorts( for r in over.rows(named=True) ) assert rows == sorted( - [("reservoir", "p2035", "p2035_low", t) + [("reservoir", "p2035_high", "p2035_low", t) for t in ("t0001", "t0002", "t0003")] - + [("reservoir", "p2040", "p2040_low", t) + + [("reservoir", "p2040_high", "p2040_low", t) for t in ("t0004", "t0005", "t0006")] ) + # The realised trunk is tied to nothing (forecast-to-forecast). + d_vals = {str(r["d"]) for r in over.rows(named=True)} + b_vals = {str(r["b"]) for r in over.rows(named=True)} + assert not ({"p2035", "p2040"} & (d_vals | b_vals)), (d_vals, b_vals) # No online / reserve providers in this fixture. assert_cstr_absent(pb, "non_anticipativity_online_integer") diff --git a/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py b/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py index edf81c734..18dea59a5 100644 --- a/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py +++ b/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py @@ -5,11 +5,15 @@ 1 stochastic-group node (hydro_reservoir), with the .mod's stochastic flag set via ``groupIncludeStochastics``. -The .mod constraint (mod:4173-4217) has domain -``(n, d, b, t) ∈ stoch_nodes × {(d, b) ∈ period__branch : d != b ∧ b ∈ period_in_use} -× dt_non_anticipativity``. For this fixture: 1 stoch node × 3 sibling -branches (after dropping the metadata-only ``period1_realized``) × 48 -realised-dispatch timesteps = 144 LP rows. +The constraint pins the per-(n, t) net storage charge equal FORECAST-TO- +FORECAST: the raw ``{(d, b) ∈ period__branch : d != b ∧ b ∈ period_in_use}`` +cohort is re-grounded (per anchor group) to a forecast star keyed on the +deterministic reference forecast ``d_ref`` (the lexicographically smallest +forecast — ``period1_lower`` here), tying the OTHER forecasts to it and +NEVER to the realised trunk ``period1``. For this fixture: 1 stoch node × +2 sibling pairs (``period1_lower`` → ``period1_mid`` / ``period1_upper``, +after dropping the trunk ``period1`` and the metadata-only +``period1_realized``) × 48 realised-dispatch timesteps = 96 LP rows. Also asserts the three other non-anticipativity families are absent (no v_online / v_reserve in this fixture). @@ -44,21 +48,35 @@ def test_non_anticipativity_storage_use_emits_full_shape( assert data.period_in_use_set is not None # Domain shape. stoch_nodes via groupStochastic ∩ group_node: - # add_stochastics group → hydro_reservoir. db_pairs: d ≠ b AND b ∈ - # period_in_use → 3 (period1 → upper/lower/mid). dtna: 48 rows for - # period1. Total: 1 × 3 × 48 = 144 LP rows. + # add_stochastics group → hydro_reservoir. After the forecast-to- + # forecast re-ground the star is d_ref=period1_lower → {period1_mid, + # period1_upper}: 2 sibling pairs (NOT 3 — the trunk period1 is no + # longer an anchor). dtna: 48 rows re-keyed onto d_ref. Total: + # 1 × 2 × 48 = 96 LP rows. n_stoch_nodes = 1 # hydro_reservoir - n_db_pairs = 3 # period1 → {upper, lower, mid} + n_db_pairs = 2 # period1_lower → {period1_mid, period1_upper} n_dtna = data.dt_non_anticipativity.height assert n_dtna == 48 expected = n_stoch_nodes * n_db_pairs * n_dtna assert_cstr_row_count(pb, "non_anticipativity_storage_use", expected) assert_cstr_present(pb, "non_anticipativity_storage_use") - # The constraint family must carry (n, d, b, t) keys. + # The constraint family must carry (n, d, b, t) keys, and the tie is + # forecast-to-forecast: the hub d is the reference forecast and the + # trunk period1 appears in NEITHER the d nor the b axis. + import polars as pl + recs = pb.cstrs_named("non_anticipativity_storage_use") assert len(recs) == 1 - assert set(recs[0].over.columns) >= {"n", "d", "b", "t"} + over = recs[0].over + assert set(over.columns) >= {"n", "d", "b", "t"} + over_u = over.with_columns( + pl.col("d").cast(pl.Utf8), pl.col("b").cast(pl.Utf8)) + assert set(over_u["d"].unique().to_list()) == {"period1_lower"} + assert set(over_u["b"].unique().to_list()) == { + "period1_mid", "period1_upper"} + assert "period1" not in over_u["d"].unique().to_list() + assert "period1" not in over_u["b"].unique().to_list() # The other three non-anticipativity families are vacuous on this # fixture: no online / no reserve providers. diff --git a/tests/engine_polars/test_configurable_nonanticipativity.py b/tests/engine_polars/test_configurable_nonanticipativity.py index 03d5726b3..350149ab5 100644 --- a/tests/engine_polars/test_configurable_nonanticipativity.py +++ b/tests/engine_polars/test_configurable_nonanticipativity.py @@ -215,25 +215,48 @@ def test_pinned_ops_set_csv_is_legacy_horizon(pinned_ops_wf) -> None: @pytest.mark.solver def test_pinned_ops_storage_na_fires(pinned_ops_resolved) -> None: """§7: unset window -> the storage NA family is present and ranges over - the whole horizon (12 rows: 6 (d,t) × the up/low sibling pairs).""" + the whole horizon. + + Under the forecast-to-forecast re-ground the cohort is a per-group star + keyed on the reference forecast (the lexicographically smallest of the + two forecasts ``up`` / ``low`` -> ``p2035_low`` / ``p2040_low``), so each + group contributes ONE sibling pair (``low`` -> ``up``) over its 3 tied + steps: 6 rows total (3 (d,t) × 2 period groups × 1 sibling), down from + the pre-re-ground 12 (which anchored both forecasts to the trunk).""" + import polars as pl + _data, _sol, pb = pinned_ops_resolved names = pb.cstr_names() assert "non_anticipativity_storage_use" in names over = pb.cstrs_named("non_anticipativity_storage_use")[0].over - assert over.height == 12 + assert over.height == 6 + # The hubs are the reference forecasts; the trunk base periods are tied + # to nothing. + ov = over.with_columns(pl.col("d").cast(pl.Utf8), pl.col("b").cast(pl.Utf8)) + assert set(ov["d"].unique().to_list()) == {"p2035_low", "p2040_low"} + assert set(ov["b"].unique().to_list()) == {"p2035_up", "p2040_up"} + assert not ({"p2035", "p2040"} & set(ov["d"].unique().to_list())) + assert not ({"p2035", "p2040"} & set(ov["b"].unique().to_list())) @pytest.mark.solver def test_pinned_ops_v_state_equal_across_branches(pinned_ops_resolved) -> None: - """§7 (byte-parity guard): with the legacy window the storage NA family - ties the branches' net-charge equal, so v_state[resv] is IDENTICAL - across mid / up / low on every constrained (d,t).""" + """§7 (re-grounded): with the legacy window the storage NA family ties + the FORECASTS' net-charge equal, so v_state[resv] is IDENTICAL across the + forecast copies ``up`` / ``low`` on every constrained (d,t). + + Forecast-to-forecast: the realized trunk ``mid`` (the bare-period key) is + NOT tied and is excluded; only the two forecast copies (the suffixed + keys) are asserted equal.""" _data, sol, _pb = pinned_ops_resolved for prefix in ("p2035", "p2040"): traj = _v_state_by_branch(sol, prefix) assert len(traj) == 3, traj - vals = list(traj.values()) - assert vals[0] == vals[1] == vals[2], (prefix, traj) + # Separate the untied trunk (bare prefix key) from the two tied + # forecast copies (suffixed keys). + forecast_vals = [v for d, v in traj.items() if d != prefix] + assert len(forecast_vals) == 2, traj + assert forecast_vals[0] == forecast_vals[1], (prefix, traj) @pytest.mark.solver diff --git a/tests/engine_polars/test_flex_2day_stochastic_dispatch_full_storage.py b/tests/engine_polars/test_flex_2day_stochastic_dispatch_full_storage.py index 03c12e324..25a959a0b 100644 --- a/tests/engine_polars/test_flex_2day_stochastic_dispatch_full_storage.py +++ b/tests/engine_polars/test_flex_2day_stochastic_dispatch_full_storage.py @@ -69,15 +69,26 @@ def test_2day_stochastic_dispatch_full_storage_parity(scenario_workdir): assert_obj_within(sol.obj, work, parquet_glob="v_obj__2day_dispatch.parquet") - # v_state must be equal across branches at every dt_non_anticipativity - # timestep (anchor period == realised period1 → siblings). - v_state_df = sol.value("v_state") - spread = (v_state_df + # v_state must be equal across the FORECAST branches at every + # dt_non_anticipativity timestep (forecast-to-forecast). Under the + # corrected ground the realised trunk ``period1`` is NO LONGER an NA hub + # — it floats and may pick a different equal-cost storage trajectory — + # so it is EXCLUDED from the cross-branch spread (the golden OBJECTIVE, + # asserted above, is unchanged: the tie was non-binding in single-solve). + v_state_df = sol.value("v_state").with_columns( + pl.col("d").cast(pl.Utf8).alias("__d")) + forecasts = v_state_df.filter(pl.col("__d") != "period1") + assert forecasts.height > 0, "no forecast-period v_state rows" + spread = (forecasts .group_by(["n", "t"]) .agg([pl.col("value").min().alias("mn"), - pl.col("value").max().alias("mx")]) + pl.col("value").max().alias("mx"), + pl.len().alias("n_fc")]) + .filter(pl.col("n_fc") > 1) .with_columns(spread=pl.col("mx") - pl.col("mn"))) + assert spread.height > 0, ( + "no (n, t) cohort with >1 forecast — spread witness vacuous") max_spread = float(spread["spread"].max()) assert max_spread < 1e-9, ( - f"v_state varies across branches by {max_spread} — non-anticipativity " - f"failed to enforce equal storage state.") + f"v_state varies across forecasts by {max_spread} — " + f"non-anticipativity failed to enforce equal storage state.") diff --git a/tests/engine_polars/test_nonanticipativity_nreal0.py b/tests/engine_polars/test_nonanticipativity_nreal0.py new file mode 100644 index 000000000..d7128412b --- /dev/null +++ b/tests/engine_polars/test_nonanticipativity_nreal0.py @@ -0,0 +1,180 @@ +"""R-NA — n_real=0 forecast-to-forecast non-anticipativity witness. + +Corrected stochastic model: the stochastic fan is FORECASTS ONLY and +non-anticipativity ties the forecasts to EACH OTHER (via a deterministic +reference forecast), never to a realised anchor. This is the capability +witness that the NA machinery works with NO realised branch (``n_real = 0``). + +Fixture ``stoch_two_period_nreal0.json`` (built via +``build_stoch_two_period_nreal0.py``): the Slice G ``free_ops`` topology — +two 3-step periods ``p2035`` / ``p2040``, branch-asymmetric wind timing, a +stochastic-group storage node ``resv`` — but with all three branches +``mid`` / ``up`` / ``low`` as FORECASTS (``realized: no``, weight 1.0 each, +``n_real = 0``) and a curated ``shared_operation_periods = [p2035, p2040]`` +window so ``dt_non_anticipativity`` populates without any realised_dispatch. + +The storage NA family must: + +* re-ground each anchor group to a per-group FORECAST star keyed on the + lexicographically-smallest forecast — ``p2035_low`` for the p2035 group, + ``p2040_low`` for the p2040 group; +* tie NO realised/trunk/base period (``p2035`` / ``p2040`` ∉ over.d ∪ over.b); +* hold ``v_state[resv]`` equal across the forecast copies at the tied steps; +* reach an Optimal solve. +""" +from __future__ import annotations + +import os + +import pytest + +# The base (phantom) period names that must never appear in an NA tie. +BASE_PERIODS = {"p2035", "p2040"} +# Expected per-group reference forecasts (lexicographically smallest). +EXPECTED_D_REF = {"p2035_low", "p2040_low"} +# Expected tied siblings (the other forecasts of each group). +EXPECTED_B = {"p2035_mid", "p2035_up", "p2040_mid", "p2040_up"} + + +@pytest.fixture(scope="module") +def nreal0_wf(scenario_workdir): + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir("nreal0", + db_fixture="stoch_two_period_nreal0") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def nreal0_resolved(nreal0_wf): + from polar_high import Problem + + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(nreal0_wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + assert sol.optimal + return data, pb, sol + + +def _na_over(pb): + """Concatenated ``over`` frame of the storage NA family (d, b, t as str).""" + import polars as pl + + recs = pb.cstrs_named("non_anticipativity_storage_use") + assert recs, "non_anticipativity_storage_use family did not fire" + over = pl.concat([r.over for r in recs]) + return over.with_columns( + pl.col("d").cast(pl.Utf8), + pl.col("b").cast(pl.Utf8), + pl.col("t").cast(pl.Utf8), + ) + + +@pytest.mark.solver +def test_nreal0_solve_optimal(nreal0_resolved): + """The n_real=0 fan solves to a strict optimum (NA fires with no + realised branch).""" + _data, _pb, sol = nreal0_resolved + assert sol.optimal + + +@pytest.mark.solver +def test_nreal0_no_realized_designation(nreal0_resolved): + """The fan carries ZERO realised members — ``n_real = 0``.""" + data, _pb, _sol = nreal0_resolved + # dt_non_anticipativity still populates (from the curated window). + assert data.dt_non_anticipativity is not None + assert data.dt_non_anticipativity.height > 0 + + +@pytest.mark.solver +def test_nreal0_db_pairs_reground_forecast_star(nreal0_resolved): + """The storage NA family re-grounds to a per-anchor-group forecast star. + + Each group ties its forecasts to the lexicographically-smallest one + (``p2035_low`` / ``p2040_low``); no row couples distinct period groups. + """ + _data, pb, _sol = nreal0_resolved + over = _na_over(pb) + + d_vals = set(over["d"].unique().to_list()) + b_vals = set(over["b"].unique().to_list()) + assert d_vals == EXPECTED_D_REF, d_vals + assert b_vals == EXPECTED_B, b_vals + + # Star shape per group: d_ref ties the OTHER two forecasts of its own + # period group, and no cross-period pair exists. + pairs = {(r["d"], r["b"]) for r in over.select("d", "b").unique() + .iter_rows(named=True)} + assert pairs == { + ("p2035_low", "p2035_mid"), ("p2035_low", "p2035_up"), + ("p2040_low", "p2040_mid"), ("p2040_low", "p2040_up"), + }, pairs + for dd, bb in pairs: + assert dd.split("_")[0] == bb.split("_")[0], (dd, bb) + + +@pytest.mark.solver +def test_nreal0_no_trunk_or_base_period_tied(nreal0_resolved): + """No realised/trunk/base period appears as an NA hub or sibling.""" + _data, pb, _sol = nreal0_resolved + over = _na_over(pb) + d_vals = set(over["d"].unique().to_list()) + b_vals = set(over["b"].unique().to_list()) + assert not (BASE_PERIODS & d_vals), (BASE_PERIODS & d_vals) + assert not (BASE_PERIODS & b_vals), (BASE_PERIODS & b_vals) + + +@pytest.mark.solver +def test_nreal0_forecasts_v_state_equal_at_tied_steps(nreal0_resolved): + """At the tied steps the forecast copies' ``v_state[resv]`` are equal. + + The committed storage decision is the same across the forecast branches + (its non-anticipative realisation), while the untied base/phantom period + is excluded. A positive number of tied cells is checked so the witness + cannot be silently vacuous. + """ + import polars as pl + + _data, pb, sol = nreal0_resolved + assert "v_state" in sol._vars, "fixture must have a storage v_state" + over = _na_over(pb) + + # Tied forecast periods (d_ref + siblings) and tied timesteps, per group. + tied_periods = set(over["d"].unique().to_list()) | set( + over["b"].unique().to_list()) + assert tied_periods and not (BASE_PERIODS & tied_periods) + + vs = sol.value("v_state") + vs = vs.with_columns( + pl.col("n").cast(pl.Utf8).alias("__n"), + pl.col("d").cast(pl.Utf8).alias("__d"), + pl.col("t").cast(pl.Utf8).alias("__t"), + ).filter((pl.col("__n") == "resv") & pl.col("__d").is_in(tied_periods)) + + tol = 1e-6 + checked = 0 + # Group by the period prefix (p2035 / p2040) so forecasts of the same + # group are compared; within each, assert equal v_state at each tied t. + vs = vs.with_columns( + pl.col("__d").str.split("_").list.first().alias("__grp")) + spread = (vs.group_by(["__grp", "__t"]) + .agg(n_fc=pl.col("value").len(), + vmax=pl.col("value").max(), + vmin=pl.col("value").min()) + .filter(pl.col("n_fc") > 1)) + for row in spread.iter_rows(named=True): + assert row["vmax"] - row["vmin"] <= tol + 1e-6 * max( + 1.0, abs(row["vmax"]), abs(row["vmin"])), ( + f"forecast v_state spread {row['vmax'] - row['vmin']} at " + f"(grp={row['__grp']}, t={row['__t']}) exceeds tol") + checked += 1 + assert checked > 0, "no tied (grp, t) cells with >1 forecast were checked" diff --git a/tests/engine_polars/test_recourse_invest_hedge.py b/tests/engine_polars/test_recourse_invest_hedge.py index c078f81e4..44a165e3b 100644 --- a/tests/engine_polars/test_recourse_invest_hedge.py +++ b/tests/engine_polars/test_recourse_invest_hedge.py @@ -122,39 +122,67 @@ def test_midhorizon_fan_period_branch_and_active_time() -> None: def test_validation_midperiod_start_raises() -> None: - """T-V (§3.3 check 1): a branch start_step that is not the first step - of its period is rejected (invest is period-granular).""" + """T-V (§3.3 check 1): a triggering cohort whose start_step is not the + first step of its period is rejected (invest is period-granular, so a + reveal must sit on a period boundary). The cohort is otherwise valid — + exactly one realized:yes and one forecast — so the ONLY violation is the + mid-period start, isolating the boundary-reveal check.""" solver = _make_solver() bad = [ - ("p2040", "rlz", "t0004", "yes", "1"), - ("p2040", "low", "t0005", "no", "3"), # not p2040's first step + ("p2040", "rlz", "t0005", "yes", "1"), # mid-period (not t0004) + ("p2040", "low", "t0005", "no", "3"), # mid-period (not t0004) ] - with pytest.raises(FlexToolConfigError, match="first step"): + with pytest.raises(FlexToolConfigError, match="period-boundary reveal"): _run(solver, bad) def test_validation_two_realized_in_period_raises() -> None: - """T-V (§3.3 check 2): a branching period with two realized:yes - branches is rejected.""" + """T-V (§3.3 check 2): the corrected model allows at most one + realized:yes branch per revealing cohort (realized is an optional + 0-or-1 designation); two realized:yes branches are rejected.""" solver = _make_solver() bad = [ ("p2040", "rlz", "t0004", "yes", "1"), ("p2040", "low", "t0004", "yes", "3"), # two realized ] - with pytest.raises(FlexToolConfigError, match="exactly one realized"): + with pytest.raises( + FlexToolConfigError, match="at most one realized:yes" + ): _run(solver, bad) -def test_validation_zero_realized_in_period_raises() -> None: - """T-V (§3.3 check 2): a branching period with no realized branch is - rejected (subsumes the old 'no realized start time' guard).""" +def test_validation_zero_realized_in_period_accepted() -> None: + """T-V (§3.3 check 2, corrected model): the stochastic fan is FORECASTS + ONLY — ``realized`` is an optional 0-or-1 designation. A branching cohort + with no realized:yes branch but at least one forecast is therefore VALID + and fans out: both forecast branches become active fan members (unlike a + realized:yes branch, which is excluded from the active fan).""" solver = _make_solver() - bad = [ + good = [ ("p2040", "rlz", "t0004", "no", "1"), ("p2040", "low", "t0004", "no", "3"), ] - with pytest.raises(FlexToolConfigError, match="exactly one realized"): - _run(solver, bad) + pb, sbtb, atl, _jumps, _fstl, _rtl, bstl = _run(solver, good) + + # p2035 trunk, p2040 trunk, then BOTH forecasts as fan branches. + assert pb["s"] == [ + ("p2035", "p2035"), + ("p2040", "p2040"), + ("p2040", "p2040_rlz"), + ("p2040", "p2040_low"), + ] + # Both forecast branches carry active time (no realized member to drop). + assert list(atl["s"].keys()) == [ + "p2035", "p2040", "p2040_rlz", "p2040_low", + ] + assert atl["s"]["p2040_rlz"] == P2[0:] + assert atl["s"]["p2040_low"] == P2[0:] + # Branch resolution: each forecast maps to its own branch; no realized + # anchor row is emitted (forecasts-only cohort). + assert ("p2040_rlz", "rlz") in sbtb["s"] + assert ("p2040_low", "low") in sbtb["s"] + # The reveal is still recorded at p2040 / t0004. + assert bstl["s"] == ("p2040", "t0004") def test_validation_fan_at_first_still_passes() -> None: @@ -409,11 +437,20 @@ def test_committed_output_realized_only(hedge_wf) -> None: @pytest.mark.solver -def test_storage_na_fires_on_reveal_pair(hedge_storage_wf) -> None: - """T-S (§5.1, §9.1): with a stochastic-group storage node the dispatch - non-anticipativity net-charge pinning fires on the reveal pair - (p2040, p2040_low) — the branch's storage state is pinned to the - realized branch at the reveal window.""" +def test_storage_na_vacuous_on_single_forecast_reveal(hedge_storage_wf) -> None: + """T-S (§5.1, §9.1), re-grounded: the hedge fixture's reveal declares a + SINGLE forecast (``low``; ``rlz`` is the realised designation = the high + scenario), so under the corrected forecast-to-forecast model there is + nothing to tie — a single forecast has no sibling — and the storage NA + family is correctly VACUOUS (the old trunk-anchored tie + ``(p2040, p2040_low)`` is gone; the realised trunk is never an NA hub). + + This pins the degenerate-fixture behaviour: slice R3 (genuine 2-forecast + fan + re-derived RP/WS/EVPI/VSS/EEV hand-calcs) will add a second + forecast and restore a forecast-to-forecast tie — at which point this + assertion is updated to the forecast star. Until then a single-forecast + reveal emitting NO NA constraint is the correct, witnessed property. + """ from polar_high import Problem from flextool.engine_polars import build_flextool, load_flextool @@ -421,10 +458,16 @@ def test_storage_na_fires_on_reveal_pair(hedge_storage_wf) -> None: pb = Problem() build_flextool(pb, data) names = pb.cstr_names() - assert "non_anticipativity_storage_use" in names - over = pb.cstrs_named("non_anticipativity_storage_use")[0].over - pairs = {(str(r["d"]), str(r["b"])) for r in over.iter_rows(named=True)} - assert pairs == {("p2040", "p2040_low")} + # A single forecast → the per-anchor-group star is skipped (<2 + # forecasts) → no family emitted. If a future change re-introduces a + # trunk-anchored tie this assertion fails loudly. + if "non_anticipativity_storage_use" in names: + over = pb.cstrs_named("non_anticipativity_storage_use")[0].over + pairs = {(str(r["d"]), str(r["b"])) + for r in over.iter_rows(named=True)} + assert not pairs, ( + "single-forecast reveal must not emit an NA tie (forecast-to-" + f"forecast is vacuous with one forecast); got {pairs}") def test_storage_branch_copy_predecessor_is_shared_trunk( diff --git a/tests/engine_polars/test_recourse_invest_na.py b/tests/engine_polars/test_recourse_invest_na.py index 41ac55307..26920bc48 100644 --- a/tests/engine_polars/test_recourse_invest_na.py +++ b/tests/engine_polars/test_recourse_invest_na.py @@ -122,37 +122,62 @@ def test_na_node_invest_coefficient_branch_weighted(_built): @pytest.mark.solver -def test_na_branch_state_equals_realized(_built): - """§15.6 witness 2: at the NA-tied timesteps the branch-leaf - ``v_state`` equals the realized value (coupling active in the - solution).""" - data, _pb, sol = _built +def test_na_forecasts_v_state_equal(_built): + """§15.6 witness 2 (re-grounded, forecast-to-forecast): at the NA-tied + timesteps the FORECAST-leaf ``v_state`` values are equal to EACH OTHER. + + Corrected model: non-anticipativity ties the forecasts together via a + deterministic reference forecast (``period1_lower`` here), NEVER to the + realized trunk ``period1``. So the coupling asserted is across the + forecast copies only; the untied trunk is excluded (it floats). The + tied forecast set is read off the emitted ``non_anticipativity_storage_use`` + cohort, so the assertion is pinned to exactly what fired. + """ + import polars as pl + + data, pb, sol = _built if "v_state" not in sol._vars: pytest.skip("fixture has no v_state variable") - vs = sol.value("v_state") - # Group v_state by (node, time) across branch periods; every period - # sharing a (node, time) NA tie must carry the same value. + + recs = pb.cstrs_named("non_anticipativity_storage_use") + assert recs, "non_anticipativity_storage_use did not fire" + over = pl.concat([r.over for r in recs]).with_columns( + pl.col("d").cast(pl.Utf8), pl.col("b").cast(pl.Utf8)) + d_ref = set(over["d"].unique().to_list()) + b_vals = set(over["b"].unique().to_list()) + # Forecast-to-forecast: the reference forecast is the lexicographically + # smallest; the trunk ``period1`` is tied to nothing. + assert d_ref == {"period1_lower"}, d_ref + assert b_vals and "period1" not in b_vals, b_vals + assert "period1" not in d_ref + tied_periods = d_ref | b_vals + assert all(p.startswith("period1_") for p in tied_periods), tied_periods + + # Group v_state by (node, time) RESTRICTED to the tied forecast periods; + # every forecast sharing a (node, time) tie must carry the same value. from collections import defaultdict tol = 1e-6 - # dt_non_anticipativity carries the (d, t) tie set; the storage NA - # equates the net-charge, so branch v_state at those (n, t) equals the - # realized value. We assert the weaker, robust invariant: for each - # (node, time), the spread across the periods present is within tol. - by_nt: dict[tuple[str, str], list[float]] = defaultdict(list) + vs = sol.value("v_state") cols = vs.columns ncol = "n" if "n" in cols else ("node" if "node" in cols else cols[0]) tcol = "t" if "t" in cols else ("time" if "time" in cols else None) - if tcol is None: - pytest.skip("v_state frame has no time column") + dcol = "d" if "d" in cols else ("period" if "period" in cols else None) + if tcol is None or dcol is None: + pytest.skip("v_state frame has no time/period column") + by_nt: dict[tuple[str, str], list[float]] = defaultdict(list) for r in vs.iter_rows(named=True): + if str(r[dcol]) not in tied_periods: + continue by_nt[(str(r[ncol]), str(r[tcol]))].append(float(r["value"])) coupled = 0 for (_n, _t), vals in by_nt.items(): if len(vals) > 1: assert max(vals) - min(vals) <= tol + 1e-6 * max(1.0, max(abs(v) for v in vals)) coupled += 1 - assert coupled > 0, "no shared (node, time) v_state rows to witness coupling" + assert coupled > 0, ( + "no shared (node, time) v_state rows across forecasts to witness " + "coupling") @pytest.mark.solver diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py index a57f074d3..5d4958081 100644 --- a/tests/engine_polars/test_rolling_stochastic_na.py +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -203,13 +203,16 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): NET CHARGE across siblings — the consecutive ``v_state`` delta ``v_state[n, d, t] - v_state[n, d, t_prev]`` (``model.py:219-234``) — NOT absolute ``v_state``. This test therefore witnesses the net charge: for - each branch-period ``d`` in the cohort (``v_state``'s ``d`` axis carries + each FORECAST-period ``d`` in the cohort (``v_state``'s ``d`` axis carries the realized trunk ``period1`` plus the three forecast copies ``period1_{lower,mid,upper}``) and node ``n``, it sorts by tied timestep ``t``, takes the consecutive ``v_state`` delta, and asserts that delta is - equal across the cohort ``d`` at each matching ``(n, t)`` tied step. The - cohort includes the realized ``period1``, so this exercises realized↔ - forecast coupling, not merely forecast↔forecast. + equal across the forecast copies at each matching ``(n, t)`` tied step. + Under the corrected (forecast-to-forecast) re-ground the trunk + ``period1`` is NO LONGER an NA hub — it floats freely — so it is EXCLUDED + from the cohort; the coupling witnessed is forecast↔forecast (the + forecasts tied to the reference forecast ``period1_lower``), never + realized↔forecast. Two witnesses run here: @@ -240,9 +243,46 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): tol = 1e-6 checked = 0 checked_abs = 0 + checked_struct = 0 + # Families that can fire for this storage-only fixture (no online / + # reserve authored). constraint_dual raises for a name that never + # emitted a row, so each lookup is guarded. + na_families = ( + "non_anticipativity_storage_use", + "non_anticipativity_storage_state_first", + ) for name, s in steps.items(): sol = s.solution assert sol is not None, f"roll {name!r} retained no solution" + + # ── Structural: the trunk period1 is NOT an NA hub or sibling ── + # Under the forecast-to-forecast re-ground every emitted NA row keys + # ``d`` on a reference FORECAST and ``b`` on another forecast; the + # realized trunk ``period1`` must appear in NO axis of any tied row. + # ``constraint_dual`` returns the over tuple packed into a single + # comma-joined ``key`` string (e.g. + # ``"hydro_reservoir,period1_lower,period1_mid,t0001"`` — n,d,b,t), + # so inspect the tokens of each key. + for fam in na_families: + try: + over = sol.constraint_dual(fam) + except Exception: + continue + if over.height == 0 or "key" not in over.columns: + continue + saw_forecast_period = False + for key in over["key"].to_list(): + toks = [tok.strip() for tok in str(key).split(",")] + # The bare trunk token ``period1`` must never appear; only + # the forecast copies ``period1_`` are tied. + assert "period1" not in toks, ( + f"roll {name!r}: trunk period1 in {fam} key {key!r}") + if any(tok.startswith("period1_") for tok in toks): + saw_forecast_period = True + assert saw_forecast_period, ( + f"roll {name!r}: {fam} has no forecast-period token") + checked_struct += 1 + if "v_state" not in sol._vars: continue @@ -270,7 +310,14 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): pl.col(dcol).cast(pl.Utf8).alias("__d"), pl.col(tcol).cast(pl.Utf8).alias("__t"), ) - .join(tied_t, on="__t", how="inner")) + .join(tied_t, on="__t", how="inner") + # Forecast-to-forecast: under the re-ground the trunk + # ``period1`` is no longer an NA hub (it floats), so it + # is excluded from both cross-forecast equality cohorts. + # The retained ``d`` are the forecast copies + # ``period1_{lower,mid,upper}`` tied to the reference + # forecast ``period1_lower``. + .filter(pl.col("__d") != "period1")) if vs_tied.height == 0: continue @@ -341,3 +388,8 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): "no tied (n, t) absolute-v_state cells with >1 branch were checked — " "the §D.6 branch-point continuity witness would be vacuous" ) + assert checked_struct > 0, ( + "no non_anticipativity_* family emitted rows to witness the " + "forecast-to-forecast hub (trunk period1 absent) — the structural " + "witness would be vacuous" + ) diff --git a/tests/expected/2_day_stochastic_dispatch/costs__dt.csv b/tests/expected/2_day_stochastic_dispatch/costs__dt.csv index 790d05754..26d4111d8 100644 --- a/tests/expected/2_day_stochastic_dispatch/costs__dt.csv +++ b/tests/expected/2_day_stochastic_dispatch/costs__dt.csv @@ -1,11 +1,11 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,upward slack penalty,downward slack penalty,inertia slack penalty,non-synchronous slack penalty,upward reserve slack penalty,downward reserve slack penalty -2day_dispatch,period1,t0001,4000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0002,2622.4538,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0003,3969.4537,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0004,3202.0873,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0005,3932.4758,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0006,4114.3959,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0007,4982.4845,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0002,1070.7065,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0003,2273.7194,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0004,3629.1605,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0005,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0006,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0007,6516.4313,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0008,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0009,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0010,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -22,9 +22,9 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1,t0021,5054.7659,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0022,5459.3445,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0023,2991.234,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0024,3976.4511,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0025,2127.4361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0026,4909.108,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0024,5976.4511,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0025,4127.4361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0026,909.108,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0027,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0028,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0029,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -33,19 +33,19 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1,t0032,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0033,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0034,4981.2752,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0035,3284.8553,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0036,2698.4998,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0035,5284.8553,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0036,698.4998,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0037,2273.281,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0038,2332.21,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0039,5002.2023,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0040,3724.6366,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0038,2240.5273,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0039,3002.2023,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0040,3413.3092,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0041,4524.7959,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0042,5728.2142,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0043,5938.8495,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0044,4409.3106,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0043,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0044,6016.029,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0045,6303.2569,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0046,4815.7709,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0047,6598.1921,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0047,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1,t0048,5733.3715,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0002,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -95,24 +95,24 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1_upper,t0046,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0047,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0048,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0001,22064.627,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0001,11397.96,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0002,11412.763,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0003,9985.4235,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0004,10630.024,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0005,12064.307,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0006,17484.525,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0007,19259.93,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0008,24719.45,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0009,27918.225,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0010,15835.271,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0011,17354.679,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0012,21101.079,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0013,20881.139,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0014,17429.995,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0015,16750.493,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0016,18457.918,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0017,18141.192,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0018,21263.077,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0006,18727.334,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0007,23211.456,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0008,19780.124,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0009,24202.611,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0010,16521.113,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0011,17470.748,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0012,21701.484,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0013,20849.08,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0014,17615.41,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0015,20773.031,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0016,20736.417,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0017,18958.044,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0018,21383.452,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0019,24689.749,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0020,23326.498,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0021,24814.242,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -120,14 +120,14 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1_lower,t0023,22792.058,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0024,26158.157,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0025,24632.694,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0026,26589.327,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0027,24751.977,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0028,17879.53,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0029,22401.643,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0030,20178.9,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0031,19135.189,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0032,23295.596,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0033,21013.332,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0026,22829.059,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0027,20897.915,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0028,20530.57,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0029,22264.03,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0030,21363.496,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0031,19671.878,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0032,25379.524,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0033,22309.022,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0034,20344.885,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0035,19374.571,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0036,20824.539,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -143,24 +143,24 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1_lower,t0046,23479.08,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0047,23281.499,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_lower,t0048,25250.41,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0001,3929.3899,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0002,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0003,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0004,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0005,1175.2947,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0006,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0007,3601.3571,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0008,5857.1066,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0009,3591.7324,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0010,3359.5078,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0011,5077.1773,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0012,3794.6245,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0013,4199.0521,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0014,5560.2736,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0015,3074.4395,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0016,3398.924,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0017,5002.3163,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0018,4120.882,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0006,466.0532,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0007,5083.1792,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0008,4004.8596,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0009,2198.3774,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0010,3616.6987,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0011,5120.7032,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0012,4019.7766,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0013,4187.0299,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0014,5629.8043,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0015,4582.8911,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0016,4253.3614,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0017,5308.6361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0018,4166.0225,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0019,4570.5286,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0020,2821.7028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0021,5902.0367,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -168,14 +168,14 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1_mid,t0023,2733.8759,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0024,5660.9074,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0025,4967.2001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0026,4359.8412,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0027,4609.3825,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0028,3629.5457,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0029,2142.3017,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0030,3155.6592,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0031,2684.3807,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0032,5051.3199,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0033,2844.5466,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0026,2949.7409,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0027,3164.1093,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0028,4623.6857,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0029,2090.6966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0030,3599.8826,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0031,2885.6392,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0032,5832.7929,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0033,3330.4303,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0034,5192.0903,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0035,2522.1423,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0036,4096.4611,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 diff --git a/tests/expected/2_day_stochastic_dispatch/summary_solve.csv b/tests/expected/2_day_stochastic_dispatch/summary_solve.csv index 6b072baf4..61e168db2 100644 --- a/tests/expected/2_day_stochastic_dispatch/summary_solve.csv +++ b/tests/expected/2_day_stochastic_dispatch/summary_solve.csv @@ -1,11 +1,11 @@ -"Diagnostic results from all solves. Output at (UTC): 2026-05-15 16:38:18.485410+00:00" +"Diagnostic results from all solves. Output at (UTC): 2026-10-07 16:43:54.956138+00:00" "Solve","Objective","Total cost from solver, includes all penalty costs" -2day_dispatch,67.3517305373 -"Total cost (calculated) full horizon (M CUR)",269.406922149,"Annualized operational, penalty and investment costs" -"Total cost (calculated) realized periods (M CUR)",269.406922149 -"Operational costs for realized periods (M CUR)",269.406922149 +2day_dispatch,67.0203687913 +"Total cost (calculated) full horizon (M CUR)",268.081475165,"Annualized operational, penalty and investment costs" +"Total cost (calculated) realized periods (M CUR)",268.081475165 +"Operational costs for realized periods (M CUR)",268.081475165 "Investment costs for realized periods (M CUR)",0 "Retirement costs / salvage revenue for realized periods (M CUR)",0 "Fixed costs for pre-existing entities (M CUR)",0 @@ -23,6 +23,6 @@ Emissions "Slack variables multiplied by timestep duration (creating or removing energy/matter, creating inertia, adding synchronous generation, decreasing capacity margin, creating reserve)" Removed, downriver, period1, 14400 -Removed, downriver, period1_lower, 14400 -Removed, downriver, period1_mid, 14400 -Removed, downriver, period1_upper, 14400 +Removed, downriver, period1_lower, 14700 +Removed, downriver, period1_mid, 14700 +Removed, downriver, period1_upper, 14700 diff --git a/tests/fixtures/build_stoch_two_period_nreal0.py b/tests/fixtures/build_stoch_two_period_nreal0.py new file mode 100644 index 000000000..f00657cd8 --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_nreal0.py @@ -0,0 +1,118 @@ +"""Build ``tests/fixtures/stoch_two_period_nreal0.json`` — the n_real=0 +forecast-to-forecast non-anticipativity witness (R-NA re-grounding). + +Corrected stochastic model: the stochastic fan is FORECASTS ONLY — there is +NO realised member in the weighted cohort, and non-anticipativity ties the +forecasts to EACH OTHER (via a deterministic reference forecast), never to a +realised anchor. This fixture is the capability witness for ``n_real = 0``: +ZERO ``realized: yes`` rows, yet the storage NA family still fires, re-grounds +to a per-anchor-group forecast star, and ties the forecasts' storage states +equal over the committed window — all with no realised/trunk period tied. + +Structure (reuses the Slice G ``stoch_two_period_free_ops`` topology — two +3-step periods ``p2035`` / ``p2040``, branch-asymmetric wind timing, a +stochastic-group storage node ``resv``, a shared investable ``base`` unit), +with two changes: + +* ``stochastic_branches`` — all three branches ``mid`` / ``up`` / ``low`` are + FORECASTS (``realized: no``), each with nonzero weight 1.0 (probabilities + 1/3 each). No ``realized: yes`` row ⟹ ``n_real = 0``. +* ``shared_operation_periods`` — a CURATED window ``[p2035, p2040]`` (the + reveal periods) so ``dt_non_anticipativity`` populates from the window + independently of any ``realized_dispatch`` (there is no realised branch to + seed the legacy union at ``n_real = 0``; design B1). + +Behaviour: the storage NA family re-grounds each anchor group to a forecast +star keyed on the lexicographically-smallest forecast (``p2035_low`` / +``p2040_low``), ties ``v_state[resv]`` equal across the forecast copies over +the windowed steps, and leaves the base/phantom period ``p2035`` / ``p2040`` +untied. Solve is Optimal. + +Run: ~/venv-spi/bin/python tests/fixtures/build_stoch_two_period_nreal0.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import build_stoch_two_period_free_ops as _free_ops + +FIXTURES = Path(__file__).resolve().parent + + +def _pack(value: object, value_type: object) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + spec = _free_ops.build() + + # n_real = 0: all three branches are FORECASTS (realized: no), weight 1.0. + stochastic_branches = _map([ + ("p2035", _nested([ + ("mid", _nested([("t0001", _nested([("no", 1.0)]))])), + ("up", _nested([("t0001", _nested([("no", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 1.0)]))])), + ])), + ]) + + # Curated NA window = the reveal periods, so dt_non_anticipativity + # populates from the window (no realized_dispatch at n_real=0). + window = _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array") + + new_pv = [] + for row in spec["parameter_values"]: + # Swap the stochastic_branches payload for the all-forecast fan. + if row[0] == "solve" and row[2] == "stochastic_branches": + row = [row[0], row[1], row[2], _pack(stochastic_branches, "map"), + row[4]] + # Drop the free_ops empty-window row (replaced by the curated window). + if row[0] == "solve" and row[2] == "shared_operation_periods": + continue + new_pv.append(row) + # Curated window lives in the ``stoch`` alternative (seen by the scenario). + new_pv.append(["solve", "stoch_2p_free", "shared_operation_periods", + window, "stoch"]) + spec["parameter_values"] = new_pv + + # Single n_real=0 scenario (drop the free_ops / pinned_ops pair and the + # ``freeops`` alternative — unused here). + spec["alternatives"] = [ + a for a in spec["alternatives"] if a[0] != "freeops" + ] + spec["scenarios"] = [ + ["nreal0", False, + "n_real=0: three forecasts (realized:no), curated NA window — " + "forecast-to-forecast storage NA with no realised branch"], + ] + spec["scenario_alternatives"] = [ + ["nreal0", "init", "base"], + ["nreal0", "base", "stoch"], + ["nreal0", "stoch", None], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_nreal0.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_nreal0.json b/tests/fixtures/stoch_two_period_nreal0.json new file mode 100644 index 000000000..1a4342f8c --- /dev/null +++ b/tests/fixtures/stoch_two_period_nreal0.json @@ -0,0 +1,6480 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzU=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solve and time structure" + ], + [ + "base", + "System data (elec demand + wind + investable base)" + ], + [ + "stoch", + "include_stochastics + branch-varying wind + t0 branches + stochastic-group storage (stochastic_invest_method=none)" + ] + ], + "scenarios": [ + [ + "nreal0", + false, + "n_real=0: three forecasts (realized:no), curated NA window \u2014 forecast-to-forecast storage NA with no realised branch" + ] + ], + "scenario_alternatives": [ + [ + "nreal0", + "init", + "base" + ], + [ + "nreal0", + "base", + "stoch" + ], + [ + "nreal0", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "node", + "resv", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_free", + null + ], + [ + "timeline", + "y6h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "timeset", + "ts2", + null + ], + [ + "unit", + "base", + null + ], + [ + "unit", + "wind", + null + ], + [ + "unit", + "charge", + null + ], + [ + "unit", + "discharge", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "group__node", + [ + "stoch_g", + "resv" + ], + null + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__inputNode", + [ + "charge", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "charge", + "resv" + ], + null + ], + [ + "unit__inputNode", + [ + "discharge", + "resv" + ], + null + ], + [ + "unit__outputNode", + [ + "discharge", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "base", + true + ], + [ + "unit", + [ + "base" + ], + "base", + true + ], + [ + "unit", + [ + "wind" + ], + "base", + true + ], + [ + "node", + [ + "resv" + ], + "stoch", + true + ], + [ + "unit", + [ + "charge" + ], + "stoch", + true + ], + [ + "unit", + [ + "discharge" + ], + "stoch", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF8ycF9mcmVlIl19", + "array" + ], + "init" + ], + [ + "timeline", + "y6h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl0sIFsicDIwNDAiLCAidHMyIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_2p_free", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMS4wXV19", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTIuMF0sIFsidDAwMDIiLCAtMTIuMF0sIFsidDAwMDMiLCAtMTIuMF0sIFsidDAwMDQiLCAtMTIuMF0sIFsidDAwMDUiLCAtMTIuMF0sIFsidDAwMDYiLCAtMTIuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjFdLCBbInQwMDAyIiwgMC4xXSwgWyJ0MDAwMyIsIDAuMV0sIFsidDAwMDQiLCAwLjFdLCBbInQwMDA1IiwgMC4xXSwgWyJ0MDAwNiIsIDAuMV1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "base", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "base", + "invest_cost", + [ + "MjAuMA==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + "other_operational_cost", + [ + "NS4w", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibWlkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuMV0sIFsidDAwMDIiLCAwLjFdLCBbInQwMDAzIiwgMC4xXV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4xXSwgWyJ0MDAwNSIsIDAuMV0sIFsidDAwMDYiLCAwLjFdXX1dXX1dLCBbInVwIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuMl0sIFsidDAwMDIiLCAwLjJdLCBbInQwMDAzIiwgMC4yXV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4wXSwgWyJ0MDAwNSIsIDAuMF0sIFsidDAwMDYiLCAwLjBdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjBdLCBbInQwMDAyIiwgMC4wXSwgWyJ0MDAwMyIsIDAuMF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMl0sIFsidDAwMDUiLCAwLjJdLCBbInQwMDA2IiwgMC4yXV19XV19XV19", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJ1cCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dXX1dXX0=", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "existing", + [ + "MTAwMC4w", + "float" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_state_start", + [ + "MC41", + "float" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_start_end_method", + [ + "ImZpeF9zdGFydCI=", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_solve_horizon_method", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ], + "stoch" + ], + [ + "node", + "resv", + "storage_state_reference_value", + [ + "MC41", + "float" + ], + "stoch" + ], + [ + "unit", + "charge", + "efficiency", + [ + "MS4w", + "float" + ], + "stoch" + ], + [ + "unit", + "charge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "stoch" + ], + [ + "unit", + "discharge", + "efficiency", + [ + "MS4w", + "float" + ], + "stoch" + ], + [ + "unit", + "discharge", + "existing", + [ + "MTAwLjA=", + "float" + ], + "stoch" + ], + [ + "unit__outputNode", + [ + "discharge", + "elec" + ], + "other_operational_cost", + [ + "MC4wMQ==", + "float" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_free", + "shared_operation_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "stoch" + ] + ] +} diff --git a/tests/scenarios.yaml b/tests/scenarios.yaml index 073cbb585..2d31c8351 100644 --- a/tests/scenarios.yaml +++ b/tests/scenarios.yaml @@ -754,6 +754,11 @@ # realised profile). After the path fix to input/group__process.csv, # the LP is constrained by min(per-branch wind profile) per step, # raising thermal dispatch by ~47%. - expected_objective: 269.406895209 + # + # Re-grounded 269.406895209 → 268.081475165 for the forecast-to-forecast + # NA change (R-NA): the trunk is de-anchored and the ties now couple only + # the forecasts to each other (star to a reference forecast), a RELAXATION + # of the old trunk==f1==f2==f3 star, so the new optimum is ≤ the old. + expected_objective: 268.081475165 expected_objective_tolerance: 1.0e-4 time_budget_seconds: 30.0 diff --git a/tests/test_non_anticipativity.py b/tests/test_non_anticipativity.py index 8cffb9738..0d54aef54 100644 --- a/tests/test_non_anticipativity.py +++ b/tests/test_non_anticipativity.py @@ -112,19 +112,21 @@ def test_dt_non_anticipativity_is_populated( def test_storage_state_equal_across_branches( stochastic_run_workdir: Path, ) -> None: - """v_state at the realised period must equal v_state at every - branch period at every dt_non_anticipativity timestep. - - The mod's ``non_anticipativity_storage_use`` constraint pins net - storage charge at (d, t) to net charge at (b, t). Combined with a - shared ``storage_state_start`` (a scalar parameter — same across - branches by construction) and the storage balance, this yields - identical v_state trajectories at every constrained timestep. + """v_state must be equal across the FORECAST branches at every + dt_non_anticipativity timestep (forecast-to-forecast). + + Corrected stochastic model: ``non_anticipativity_storage_use`` pins the + per-(n, t) net storage charge equal across the FORECAST copies via a + deterministic reference forecast — it does NOT anchor the forecasts to + the realised trunk period (``period1``), which is no longer an NA hub and + floats freely (it may pick a different equal-cost storage trajectory). + So the invariant witnessed is: the forecast periods carry identical + v_state trajectories at every constrained timestep; the realised trunk is + EXCLUDED. A regression where the constraint's index domain is wrong (e.g. period__branch swapped with branch__period, or dt_non_anticipativity - pulled from the wrong CSV) would let the branches' storage states - drift. + pulled from the wrong CSV) would let the forecasts' storage states drift. """ workdir = stochastic_run_workdir matches = list( @@ -141,43 +143,49 @@ def test_storage_state_equal_across_branches( dtna = _read_csv_pairs( workdir / "solve_data" / "dt_non_anticipativity_set.csv" ) - dtna_by_d: dict[str, set[str]] = {} - for (d, t) in dtna: - dtna_by_d.setdefault(d, set()).add(t) + tied_timesteps = {t for (_d, t) in dtna} # Periods that actually have v_state rows — branches that aren't in # period_in_use (e.g. a metadata-only "..._realized" branch) would # be absent from v_state and we should skip them. periods_with_rows = set(df.index.get_level_values("period")) - pairs_checked = 0 - for (d, b) in pb: - if d == b: - continue - if d not in periods_with_rows or b not in periods_with_rows: - # b not in period_in_use — constraint domain excludes it. - continue - timesteps = dtna_by_d.get(d, set()) - for t in timesteps: - try: - row_d = df.xs((d, t), level=("period", "time")) - row_b = df.xs((b, t), level=("period", "time")) - except KeyError: - continue - for node in df.columns: - v_d = float(row_d.iloc[0][node]) - v_b = float(row_b.iloc[0][node]) - assert v_d == pytest.approx(v_b, abs=1e-6), ( - f"non-anticipativity violated for node={node!r}, " - f"d={d!r}, b={b!r}, t={t!r}: " - f"v_state[d]={v_d}, v_state[b]={v_b}" + # Forecast periods = the branch (b) side of the off-diagonal + # period__branch pairs; the anchor (d) side is the realised trunk and is + # EXCLUDED (it is no longer tied under the forecast-to-forecast ground). + trunks = {d for (d, b) in pb if d != b} + forecast_periods = sorted( + {b for (d, b) in pb if d != b and b in periods_with_rows} + ) + assert forecast_periods, ( + "no forecast periods in period_in_use — fixture degenerated" + ) + + checked = 0 + for t in sorted(tied_timesteps): + for node in df.columns: + vals = [] + for f in forecast_periods: + try: + row_f = df.xs((f, t), level=("period", "time")) + except KeyError: + continue + vals.append(float(row_f.iloc[0][node])) + if len(vals) > 1: + assert max(vals) - min(vals) == pytest.approx(0.0, abs=1e-6), ( + f"forecast-to-forecast non-anticipativity violated for " + f"node={node!r}, t={t!r}: v_state spread " + f"{max(vals) - min(vals)} across forecasts {forecast_periods}" ) - pairs_checked += 1 + checked += 1 - assert pairs_checked > 0, ( - "No (d, b, t) tuples were checked — fixture failed to produce " - "branches in period_in_use, or v_state has no storage nodes." + assert checked > 0, ( + "No (forecast, t) cohorts with >1 forecast were checked — fixture " + "failed to produce multiple forecast branches in period_in_use." ) + # Sanity: the trunk really is present but excluded (a forecast-to-forecast + # ground, not a trunk-anchored one). + assert trunks and trunks <= periods_with_rows, trunks # --------------------------------------------------------------------------- From 42cad0ba5f8c5a164a5ab0e4239e875dae6f1e17 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Thu, 8 Oct 2026 11:16:31 +0300 Subject: [PATCH 77/87] feat(stochastic): reference forecast owns real period names at n_real=0 (R-NAME) Under the forecasts-only model a mid-horizon fan with no realized designation had nothing owning the real (un-suffixed) period names, so pre-reveal branch-keyed data fell through to the zero fallback and the solve silently corrupted (e.g. the hedge over-invested because its pre-reveal wind profile read as 0). Fix: in create_stochastic_periods, when the realized-branch search finds none (found==0) for a PRE-REVEAL period, bind that period's solve_branch__time_branch to the reference forecast (d_ref = the lexically-smallest forecast token, the same hub R-NA's star uses), so its data binds correctly. Pre-reveal-scoped on purpose: binding the post-reveal weight-0 phantom would join the recourse lineage chain and trip the leaf-weight-constancy precondition. Keeps "realized" optional (not mandated for mid-horizon). Byte-parity on all existing tests: the guard fires only for the n_real=0 mid-horizon topology, which had no prior fixture. New witness fixture build_stoch_two_period_midhorizon_nreal0 + test asserting the pre-reveal profile binds nonzero, SBTB owns (pre_reveal_period, d_ref), and the here-and-now invest is correct (fails for the right reasons if the guard is removed). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_stochastic.py | 60 +- tests/conftest.py | 32 + .../test_rname_pre_reveal_ownership.py | 238 + ...uild_stoch_two_period_midhorizon_nreal0.py | 264 + .../stoch_two_period_midhorizon_nreal0.json | 6288 +++++++++++++++++ 5 files changed, 6880 insertions(+), 2 deletions(-) create mode 100644 tests/engine_polars/test_rname_pre_reveal_ownership.py create mode 100644 tests/fixtures/build_stoch_two_period_midhorizon_nreal0.py create mode 100644 tests/fixtures/stoch_two_period_midhorizon_nreal0.json diff --git a/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index d896b0b27..001f679f3 100644 --- a/flextool/engine_polars/_stochastic.py +++ b/flextool/engine_polars/_stochastic.py @@ -400,6 +400,16 @@ def create_stochastic_periods( and zero-weight branches); ``solve_branch__time_branch`` gains rows only for branches that received active time. + Real-name data ownership has a 0-or-1 realized designation: when + a period has a realized branch it owns the real names' data; when + the whole fan is forecasts-only (n_real=0) no realized branch + owns them, so a PRE-REVEAL real-named period (the shared trunk) + instead binds the REFERENCE forecast (the lexically-smallest + forecast raw token) so its branch-keyed data does not fall through + to the zero fallback. Reveal / continuation periods carry their + data on the suffixed forecast copies; their real-named self-row is + a harmless weight-0 phantom left untouched (R-NAME). + IMPORTANT: branches do NOT enter ``invest_periods`` either. Per ``audit/a6_b_dim_alternative.md`` (R-O6), ``v_invest`` is realized-only — recourse investment is a future capability. @@ -755,9 +765,23 @@ def create_stochastic_periods( if period in fix_storage_time_list: new_fix_storage_time_list[period] = fix_storage_time_list[period] - # Find the realized branch for each period. Realized is an + # Find the realized branch for each period, and bind the + # reference forecast where none is designated. Realized is an # optional 0-or-1 designation: a period may have no realized - # row (forecasts-only cohort) or exactly one; 2+ → raise. + # row (forecasts-only cohort) or exactly one; 2+ → raise. When + # NO branch is realized (n_real=0), the real (un-suffixed) + # period names are owned by no realized branch, so a PRE-REVEAL + # real-named period (the shared trunk, which has no suffixed + # forecast copy of its own) would get no solve_branch__time_branch + # owner and its branch-keyed data (profiles, pdt params) would + # silently fall through to the zero fallback. In that case the + # REFERENCE forecast (the lexically-smallest forecast raw token — + # the same d_ref R-NA's db_pairs star uses) owns the real names + # for pre-reveal periods so their data binds. Reveal and + # continuation periods carry their data on the suffixed forecast + # copies, so their real-named self-row stays a harmless weight-0 + # phantom we leave alone (binding it would collide with the + # recourse lineage chain grouping — see R-NAME design §2). for period, active_time in active_time_list.items(): found = 0 # Before branching: row[0]==period, row[2]==first step, @@ -790,6 +814,38 @@ def create_stochastic_periods( self.logger.error(message) raise FlexToolConfigError(message) + # R-NAME: when no realized branch owns the real names + # (n_real=0), a PRE-REVEAL real-named period (the shared + # trunk) binds the reference forecast's data — the + # lexically-smallest forecast raw token, the same d_ref + # R-NA's db_pairs star uses. Scoped to pre-reveal ONLY: a + # blanket binding would also point the POST-reveal weight-0 + # phantom self-row at d_ref, making it join the recourse + # lineage chain and trip the leaf-weight-constancy + # precondition (_derived_branch.py). The lineage precondition + # already excludes pre-reveal anchors, so the pre-reveal trunk + # is safe. A pre-reveal period has no suffixed copy of its + # own, so this is the only owner it can get. + if found == 0 and active_branches: + bs = branch_start_time_lists[solve] + reveal_period = bs[0] if bs is not None else None + order = list(active_time_list.keys()) + if ( + reveal_period is not None + and period in order + and reveal_period in order + and order.index(period) < order.index(reveal_period) + ): + # For a hypothetical multi-reveal solve, scope d_ref + # to the FIRST reveal cohort's forecasts; with a + # single reveal (every current fixture) active_branches + # IS exactly that cohort, so its lexical-min token is + # R-NA's reference forecast. + d_ref_tok = sorted(set(active_branches))[0] + solve_branch__time_branch_lists[solve].append( + (period, d_ref_tok) + ) + # Update the time lists for this solve. realized_time_lists[solve] = new_realized_time_list fix_storage_time_lists[solve] = new_fix_storage_time_list diff --git a/tests/conftest.py b/tests/conftest.py index 0faf20766..442e85b4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -605,6 +605,33 @@ def stoch_two_period_nreal0_db_url( return url +@pytest.fixture(scope="session") +def stoch_two_period_midhorizon_nreal0_db_url( + tmp_path_factory: pytest.TempPathFactory, +) -> str: + """``stoch_two_period_midhorizon_nreal0`` fixture DB — the R-NAME + pre-reveal data-binding witness. + + Built from ``tests/fixtures/stoch_two_period_midhorizon_nreal0.json`` + (generated by ``build_stoch_two_period_midhorizon_nreal0.py``): a + mid-horizon reveal (at ``p2040``) with a genuine forecasts-only + ``{high, low}`` fan (both ``realized: no``, nonzero weight ⟹ + ``n_real = 0``) on the DEFAULT ``stochastic_invest_method = none`` + path. ``p2035`` is a lone PRE-REVEAL trunk whose branch-keyed wind + profile must bind the reference forecast (lexically-smallest token + ``high``) — without R-NAME it would fall through to the zero fallback. + Defensive migration as in :func:`stochastic_db_url`. + """ + from flextool.update_flextool.db_migration import migrate_database + + db_path = (tmp_path_factory.mktemp("db_stpmn0") + / "stoch_two_period_midhorizon_nreal0.sqlite") + url = json_to_db( + FIXTURES_DIR / "stoch_two_period_midhorizon_nreal0.json", db_path) + migrate_database(url) + return url + + @pytest.fixture(scope="session") def branch2_parent_period_db_url( tmp_path_factory: pytest.TempPathFactory, @@ -704,6 +731,8 @@ def examples_db_url(examples_db_path: Path) -> str: "stoch_two_period_hedge": "stoch_two_period_hedge_db_url", "stoch_two_period_free_ops": "stoch_two_period_free_ops_db_url", "stoch_two_period_nreal0": "stoch_two_period_nreal0_db_url", + "stoch_two_period_midhorizon_nreal0": + "stoch_two_period_midhorizon_nreal0_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -751,6 +780,7 @@ def scenario_workdir( stoch_two_period_hedge_db_url, stoch_two_period_free_ops_db_url, stoch_two_period_nreal0_db_url, + stoch_two_period_midhorizon_nreal0_db_url, branch2_parent_period_db_url, case14_db_url, test_solver_config_dir, @@ -811,6 +841,8 @@ def test_foo(scenario_workdir): "stoch_two_period_hedge": stoch_two_period_hedge_db_url, "stoch_two_period_free_ops": stoch_two_period_free_ops_db_url, "stoch_two_period_nreal0": stoch_two_period_nreal0_db_url, + "stoch_two_period_midhorizon_nreal0": + stoch_two_period_midhorizon_nreal0_db_url, "branch2_parent_period": branch2_parent_period_db_url, "case14": case14_db_url, } diff --git a/tests/engine_polars/test_rname_pre_reveal_ownership.py b/tests/engine_polars/test_rname_pre_reveal_ownership.py new file mode 100644 index 000000000..f86249104 --- /dev/null +++ b/tests/engine_polars/test_rname_pre_reveal_ownership.py @@ -0,0 +1,238 @@ +"""R-NAME — the reference forecast owns the real period names when n_real=0. + +Corrected stochastic model: the stochastic fan is FORECASTS ONLY and the +``realized`` tag is an OPTIONAL 0-or-1 designation. When a mid-horizon fan +carries NO realized branch (``n_real = 0``), no branch owns the un-suffixed +(real) period names, so a PRE-REVEAL real-named period — the shared trunk, +which has no suffixed forecast copy of its own — would get no +``solve_branch__time_branch`` (SBTB) owner and its branch-keyed data +(profiles, pdt params) would silently fall through to the zero fallback. + +R-NAME binds such a pre-reveal trunk to the REFERENCE forecast (the +lexically-smallest forecast raw token — the same ``d_ref`` R-NA's +``db_pairs`` star uses) so the pre-reveal data binds instead of zeroing. +The guard is scoped to PRE-REVEAL periods only: the post-reveal weight-0 +phantom self-row is left untouched (binding it would collide with the +recourse lineage chain grouping — see the R-NAME design). + +Two witnesses, both of which fail if the guard is removed: + +* ``test_rname_sbtb_binds_reference_forecast_prereveal`` — solver-free: drive + ``create_stochastic_periods`` directly with a hand-built n_real=0 + mid-horizon fan and assert the pre-reveal SBTB row ``(p2035, high)`` appears + (``high`` = lexical-min forecast) while the reveal period's phantom stays + unbound. +* the solve-based tests on the ``stoch_two_period_midhorizon_nreal0`` fixture + (DEFAULT ``stochastic_invest_method = none``) — the pre-reveal wind binds + NONZERO (so ``base`` need only cover the real residual 60, not the inflated + 120) and the here-and-now invest is the sensible 60. +""" +from __future__ import annotations + +import logging +from collections import defaultdict +from types import SimpleNamespace + +import pytest + +from flextool.engine_polars._solve_state import ActiveTimeEntry +from flextool.engine_polars._stochastic import StochasticSolver + + +# --------------------------------------------------------------------------- +# Solver-free unit witness: the guard writes the pre-reveal SBTB owner. +# --------------------------------------------------------------------------- +def _ats(names_and_indices): + return [ActiveTimeEntry(timestep=t, index=i, duration=1.0) + for t, i in names_and_indices] + + +P1 = _ats([("t0001", 0), ("t0002", 1), ("t0003", 2)]) +P2 = _ats([("t0004", 3), ("t0005", 4), ("t0006", 5)]) + +# Genuine forecasts-only fan revealed at p2040 (mid-horizon): both ``high`` +# and ``low`` are ``realized: no`` with nonzero weight ⟹ n_real = 0. +# (period, branch, start_step, realized, weight) +BRANCH_INFO_NREAL0_MID = [ + ("p2040", "high", "t0004", "no", "1"), + ("p2040", "low", "t0004", "no", "3"), +] + + +def _make_solver() -> StochasticSolver: + state = SimpleNamespace( + logger=logging.getLogger("test_rname_pre_reveal_ownership"), + timeline=SimpleNamespace(stochastic_timesteps=defaultdict(list)), + ) + return StochasticSolver(state) + + +def test_rname_sbtb_binds_reference_forecast_prereveal() -> None: + """n_real=0 mid-horizon fan: the pre-reveal trunk's real name binds the + reference forecast (lexical-min token), and the reveal period's weight-0 + phantom is left unbound.""" + solver = _make_solver() + active = {"p2035": P1, "p2040": P2} + realized = {"p2035": P1[0:], "p2040": P2[0:]} + fix_storage: dict = {} + + (_pb, sbtb, _atl, _jumps, _fstl, _rtl, _bstl) = ( + solver.create_stochastic_periods( + {"s": BRANCH_INFO_NREAL0_MID}, + ["s"], + {"s": "s"}, + {"s": active}, + {"s": fix_storage}, + {"s": realized}, + ) + ) + rows = sbtb["s"] + + # d_ref is the lexically-smallest forecast raw token. + d_ref = sorted({"high", "low"})[0] + assert d_ref == "high" + + # The PRE-REVEAL trunk p2035 binds the reference forecast (the R-NAME + # guard row). Without the guard this row is absent and p2035's + # branch-keyed data falls through to the zero fallback. + assert ("p2035", d_ref) in rows, rows + + # Scope check: the fan copies carry their own data; the reveal period's + # weight-0 phantom self-row is NEVER bound by the guard (that would trip + # the recourse lineage chain precondition). + assert ("p2040_high", "high") in rows, rows + assert ("p2040_low", "low") in rows, rows + assert ("p2040", "high") not in rows, rows + assert ("p2040", "low") not in rows, rows + + +# --------------------------------------------------------------------------- +# Solve-based witness: the pre-reveal profile binds nonzero (not the zero +# fallback) and the here-and-now invest is the sensible residual-driven value. +# --------------------------------------------------------------------------- +SOLVE = "stoch_2p_mid" +PRE_REVEAL_PERIOD = "p2035" +# Reference forecast = lexically-smallest forecast raw token of the fan. +D_REF = "high" +# With the guard: p2035 wind binds 0.6·100 = 60 → the investable ``base`` +# covers the real pre-reveal residual 120 − 60 = 60. Without the guard the +# profile zeroes → residual 120 → invest forced to 120 (the broken artifact). +EXPECTED_PRE_REVEAL_INVEST = 60.0 +EXPECTED_PRE_REVEAL_BASE_DISPATCH = 60.0 + + +@pytest.fixture(scope="module") +def midhorizon_wf(scenario_workdir): + import os + + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + return scenario_workdir( + "midhorizon", db_fixture="stoch_two_period_midhorizon_nreal0") + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + + +@pytest.fixture(scope="module") +def midhorizon_outputs(midhorizon_wf): + """Re-solve from the workdir and return (optimal, v_flow, v_invest, sbtb).""" + import polars as pl + + from polar_high import Problem + + from flextool.engine_polars import build_flextool, load_flextool + + data = load_flextool(midhorizon_wf) + pb = Problem() + build_flextool(pb, data) + sol = pb.solve() + + v_flow = pl.read_parquet( + midhorizon_wf / "output_raw" / f"v_flow__{SOLVE}.parquet") + v_invest = pl.read_parquet( + midhorizon_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") + sbtb = pl.read_csv( + midhorizon_wf / "solve_data" / "solve_branch__time_branch.csv") + return sol.optimal, v_flow, v_invest, sbtb + + +def _wind_col(df): + return next(c for c in df.columns if "wind" in c) + + +def _base_col(df): + return next(c for c in df.columns if c.startswith("('base'")) + + +@pytest.mark.solver +def test_midhorizon_solve_optimal(midhorizon_outputs): + """The n_real=0 mid-horizon ``none`` solve reaches a strict optimum.""" + optimal, *_ = midhorizon_outputs + assert optimal + + +@pytest.mark.solver +def test_midhorizon_prereveal_profile_binds_nonzero(midhorizon_outputs): + """The pre-reveal trunk's branch-keyed wind profile binds NONZERO. + + Contrast: without R-NAME the profile falls through to the zero fallback + (wind 0 at p2035) and ``base`` is forced to supply the full 120. With the + guard, free wind binds to its cap so ``base`` supplies only the real + residual 60. + """ + _opt, v_flow, _inv, _sbtb = midhorizon_outputs + pre = v_flow.filter(v_flow["period"] == PRE_REVEAL_PERIOD) + assert pre.height > 0 + + wind = pre[_wind_col(pre)].to_list() + base = pre[_base_col(pre)].to_list() + # Wind bound nonzero (not the zero fallback) at every pre-reveal step. + assert all(w > 0.0 for w in wind), wind + # ``base`` covers only the real residual 60 (would be 120 under the + # zero fallback) — a non-degenerate witness: base carries a positive + # dispatch cost so free wind is strictly preferred up to its cap. + assert base == pytest.approx( + [EXPECTED_PRE_REVEAL_BASE_DISPATCH] * len(base)), base + + +@pytest.mark.solver +def test_midhorizon_prereveal_sbtb_owner_is_reference_forecast( + midhorizon_outputs, +): + """SBTB binds the pre-reveal trunk to the reference forecast (lexical-min + token), and the reveal-period phantom stays unbound.""" + _opt, _flow, _inv, sbtb = midhorizon_outputs + pairs = {(r["period"], r["branch"]) + for r in sbtb.iter_rows(named=True)} + + # The forecast fan tokens; the reference is the lexically-smallest. + forecasts = {b for (p, b) in pairs if p.startswith("p2040_")} + assert forecasts, pairs + assert D_REF == sorted(forecasts)[0] == "high" + + # The pre-reveal trunk real name is owned by the reference forecast. + assert (PRE_REVEAL_PERIOD, D_REF) in pairs, pairs + # Pre-reveal scope: the reveal period's weight-0 phantom is NOT bound. + assert ("p2040", "high") not in pairs, pairs + assert ("p2040", "low") not in pairs, pairs + + +@pytest.mark.solver +def test_midhorizon_here_and_now_invest_is_sensible(midhorizon_outputs): + """The here-and-now (pre-reveal) invest covers the REAL residual 60. + + Driven by this fixture's own data: pre-reveal residual 60 (demand 120 − + wind 60) is the binding period for the single ``none`` invest var + (cheaper ``invest_cost`` at p2035; both post-reveal residuals 20/40 are + smaller), so the shared first-stage capacity is 60. Without the guard the + pre-reveal residual inflates to 120 and the invest is forced to 120. + """ + _opt, _flow, v_invest, _sbtb = midhorizon_outputs + pre = v_invest.filter(v_invest["period"] == PRE_REVEAL_PERIOD) + assert pre.height == 1, pre + assert pre["base"].to_list()[0] == pytest.approx( + EXPECTED_PRE_REVEAL_INVEST), pre diff --git a/tests/fixtures/build_stoch_two_period_midhorizon_nreal0.py b/tests/fixtures/build_stoch_two_period_midhorizon_nreal0.py new file mode 100644 index 000000000..26946cec9 --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_midhorizon_nreal0.py @@ -0,0 +1,264 @@ +"""Build ``tests/fixtures/stoch_two_period_midhorizon_nreal0.json`` — the +R-NAME witness: a mid-horizon reveal with a genuine forecasts-only fan +(n_real=0) on the DEFAULT ``stochastic_invest_method = none`` path. + +Topology (one 3-step pre-reveal trunk + one 3-step fanned period): + + * Two 3-step periods ``p2035`` (t0001-t0003) / ``p2040`` (t0004-t0006). + * Reveal at **p2040** (mid-horizon), so ``p2035`` is a LONE PRE-REVEAL + TRUNK (not fanned) whose real (un-suffixed) names must source their + branch-keyed wind profile from SOME branch. + * The fan is a genuine ``{high, low}`` pair — BOTH ``realized: no`` with + nonzero weight, so **n_real = 0**. No branch owns the real period + names. Before R-NAME the pre-reveal trunk's branch-keyed wind profile + silently collapsed to the zero fallback (no ``solve_branch__time_branch`` + owner for the real names), inflating the pre-reveal residual and forcing + the here-and-now invest up. R-NAME binds the pre-reveal trunk to the + reference forecast (lexically-smallest token = ``high``) so p2035's wind + binds nonzero. + +DEFAULT invest path: ``stochastic_invest_method = none`` (single invest var +per period; operations fan stochastically across the forecasts). This keeps +the witness clear of the recourse / lineage machinery so it exercises ONLY +the pre-reveal data-binding guard. + +Numbers are chosen so the guard's effect is DIRECTLY observable on BOTH the +pre-reveal dispatch and the here-and-now investment (not just structurally): + + * The investable ``base`` carries a small positive ``other_operational_cost`` + so the free ``wind`` is STRICTLY preferred up to its profile cap (no + dispatch degeneracy) — then ``wind``'s pre-reveal flow equals its bound + profile iff the profile binds, and collapses to 0 under the zero fallback. + * The pre-reveal residual is the BINDING period for the single ``none`` + invest var (``invest_cost`` cheaper at p2035; p2035's residual exceeds the + post-reveal forecasts'), so the here-and-now invest tracks the pre-reveal + residual — 60 when p2035's profile binds, 120 under the zero fallback. + +Branch-varying wind profile (share of the 100 MW free ``wind`` cap): + * p2035 (t0001, pre-reveal / shared): 0.6 in BOTH branches → wind 60 → + pre-reveal residual the investable ``base`` must cover = 120 − 60 = 60. + Under the zero fallback (guard removed) this collapses to 0 → residual 120. + * p2040 (t0004), ``high``: 1.0 → wind 100 → residual 20. + * p2040 (t0004), ``low`` : 0.8 → wind 80 → residual 40. + Both post-reveal residuals (20, 40) are below the pre-reveal 60, so a single + p2035 invest of 60 (persisting forward under the unbounded invest window) + covers every period; p2040 invests nothing. + +Elec demand = 120 MW at every step (deterministic inflow). + +Run: ~/venv-spi/bin/python \ + tests/fixtures/build_stoch_two_period_midhorizon_nreal0.py +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent + +STEPS = [f"t{i:04d}" for i in range(1, 7)] +P1_STEPS = STEPS[:3] +P2_STEPS = STEPS[3:] + + +def _pack(value: object, value_type: str) -> list: + return [ + base64.b64encode(json.dumps(value).encode()).decode("ascii"), + value_type, + ] + + +def _map(pairs: list[tuple[str, object]]) -> dict: + return {"index_type": "str", "data": [[k, v] for k, v in pairs]} + + +def _nested(pairs: list[tuple[str, object]]) -> dict: + return {"type": "map", "index_type": "str", + "data": [[k, v] for k, v in pairs]} + + +def build() -> dict: + src = json.loads((FIXTURES / "stochastics.json").read_text()) + + spec: dict = { + key: src[key] + for key in ( + "entity_classes", + "parameter_value_lists", + "parameter_groups", + "parameter_definitions", + "parameter_types", + ) + } + + # ``stochastics.json`` still carries the pre-v70 schema, so inject the + # ``solve.stochastic_invest_method`` opt-in (Slice C, v70) before + # import (json_to_db validates against the embedded schema BEFORE the + # idempotent v70 migration reconciles it). + if not any(vl[0] == "stochastic_invest_methods" + for vl in spec["parameter_value_lists"]): + spec["parameter_value_lists"] += [ + ["stochastic_invest_methods", _pack("none", "str")], + ["stochastic_invest_methods", _pack("recourse", "str")], + ] + if not any(pd[0] == "solve" and pd[1] == "stochastic_invest_method" + for pd in spec["parameter_definitions"]): + spec["parameter_definitions"].append([ + "solve", "stochastic_invest_method", + _pack("none", "str"), "stochastic_invest_methods", + "How stochastic branches participate in investment " + "(none/recourse).", "solve_advanced", + ]) + + spec["alternatives"] = [ + ["init", "Model, solve and time structure"], + ["base", "System data (base invest + wind + elec demand)"], + ["stoch", "include_stochastics + branch-varying wind + " + "mid-horizon reveal (none invest path)"], + ] + spec["scenarios"] = [ + ["midhorizon", False, + "Mid-horizon reveal at p2040, genuine n_real=0 {high,low} fan, " + "stochastic_invest_method=none (R-NAME witness)"], + ] + spec["scenario_alternatives"] = [ + ["midhorizon", "init", "base"], + ["midhorizon", "base", "stoch"], + ["midhorizon", "stoch", None], + ] + + spec["entities"] = [ + ["group", "stoch_g", None], + ["model", "flextool", None], + ["node", "elec", None], + ["profile", "wprof", None], + ["solve", "stoch_2p_mid", None], + ["timeline", "y6h", None], + ["timeset", "ts1", None], + ["timeset", "ts2", None], + ["unit", "base", None], + ["unit", "wind", None], + ["group__unit", ["stoch_g", "wind"], None], + ["unit__outputNode", ["base", "elec"], None], + ["unit__outputNode", ["wind", "elec"], None], + ["unit__node__profile", ["wind", "elec", "wprof"], None], + ] + spec["entity_alternatives"] = [ + ["node", ["elec"], "init", True], + ["unit", ["base"], "init", True], + ["unit", ["wind"], "init", True], + ] + + # ---- init: model / solve / time structure -------------------------- + # Mid-horizon reveal: a GENUINE forecasts-only fan declared at p2040 — + # both ``high`` and ``low`` are ``realized: no`` with nonzero weight, so + # n_real = 0. + stochastic_branches = _map([ + ("p2040", _nested([ + ("high", _nested([("t0004", _nested([("no", 1.0)]))])), + ("low", _nested([("t0004", _nested([("no", 3.0)]))])), + ])), + ]) + # Deterministic profile (base alternative): 0.6 at p2035 (wind 60 → + # residual 60), 0.0 at p2040. + profile_base = _map( + [(t, 0.60) for t in P1_STEPS] + [(t, 0.0) for t in P2_STEPS] + ) + # Branch-varying wind profile (stoch alternative). Keyed + # branch → period-first-step → per-step share of the 100 MW wind cap. + # p2035 (t0001) is SHARED (0.6 in BOTH branches — pre-reveal data is + # identical). p2040 (t0004): high 1.0 (residual 20), low 0.8 + # (residual 40). + profile_stoch = _map([ + ("high", _nested([ + ("t0001", _nested([(t, 0.60) for t in P1_STEPS])), + ("t0004", _nested([(t, 1.0) for t in P2_STEPS])), + ])), + ("low", _nested([ + ("t0001", _nested([(t, 0.60) for t in P1_STEPS])), + ("t0004", _nested([(t, 0.80) for t in P2_STEPS])), + ])), + ]) + # Deterministic demand: 120 MW at every step (both periods). + inflow = _map([(t, -120.0) for t in STEPS]) + # Period-Map invest_cost: first stage cheap (1), later period dear (3). + invest_cost = _map([("p2035", 1.0), ("p2040", 3.0)]) + + spec["parameter_values"] = [ + ["model", "flextool", "solves", + _pack({"value_type": "str", "data": ["stoch_2p_mid"]}, "array"), + "init"], + ["timeline", "y6h", "timestep_duration", + _pack(_map([(t, 1.0) for t in STEPS]), "map"), "init"], + ["timeset", "ts1", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts1", "timeset_duration", + _pack(_map([("t0001", 3.0)]), "map"), "init"], + ["timeset", "ts2", "timeline", _pack("y6h", "str"), "init"], + ["timeset", "ts2", "timeset_duration", + _pack(_map([("t0004", 3.0)]), "map"), "init"], + ["solve", "stoch_2p_mid", "period_timeset", + _pack(_map([("p2035", "ts1"), ("p2040", "ts2")]), "map"), "init"], + ["solve", "stoch_2p_mid", "realized_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_mid", "invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_mid", "realized_invest_periods", + _pack({"value_type": "str", "data": ["p2035", "p2040"]}, "array"), + "init"], + ["solve", "stoch_2p_mid", "solve_mode", + _pack("single_solve", "str"), "init"], + ["solve", "stoch_2p_mid", "years_represented", + _pack(_map([("p2035", 1.0), ("p2040", 1.0)]), "map"), "init"], + # ---- base: system data ---------------------------------------- + ["node", "elec", "node_type", _pack("balance", "str"), "base"], + ["node", "elec", "penalty_up", _pack(10000.0, "float"), "base"], + ["node", "elec", "penalty_down", _pack(10000.0, "float"), "base"], + ["node", "elec", "inflow", _pack(inflow, "map"), "base"], + ["unit", "wind", "existing", _pack(100.0, "float"), "base"], + ["unit", "wind", "efficiency", _pack(1.0, "float"), "base"], + ["unit__node__profile", ["wind", "elec", "wprof"], "profile_method", + _pack("upper_limit", "str"), "base"], + ["profile", "wprof", "profile", _pack(profile_base, "map"), "base"], + ["unit", "base", "efficiency", _pack(1.0, "float"), "base"], + ["unit", "base", "existing", _pack(0.0, "float"), "base"], + ["unit", "base", "virtual_unitsize", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_method", + _pack("invest_total", "str"), "base"], + ["unit", "base", "invest_cost", _pack(invest_cost, "map"), "base"], + ["unit", "base", "discount_rate", _pack(0.05, "float"), "base"], + ["unit", "base", "lifetime", _pack(1.0, "float"), "base"], + ["unit", "base", "invest_max_total", _pack(150.0, "float"), + "base"], + # Small positive dispatch cost so free wind is STRICTLY preferred up + # to its profile cap (removes dispatch degeneracy → wind's pre-reveal + # flow cleanly witnesses the bound profile). + ["unit__outputNode", ["base", "elec"], "other_operational_cost", + _pack(1.0, "float"), "base"], + # ---- stoch: branches + branch-varying profile ----------------- + # Default invest path: stochastic_invest_method = none (single + # invest var per period; ops fan across the forecasts). + ["group", "stoch_g", "include_stochastics", _pack("yes", "str"), + "stoch"], + ["profile", "wprof", "profile", _pack(profile_stoch, "map"), + "stoch"], + ["solve", "stoch_2p_mid", "stochastic_invest_method", + _pack("none", "str"), "stoch"], + ["solve", "stoch_2p_mid", "stochastic_branches", + _pack(stochastic_branches, "map"), "stoch"], + ] + return spec + + +def main() -> None: + spec = build() + out = FIXTURES / "stoch_two_period_midhorizon_nreal0.json" + out.write_text(json.dumps(spec, indent=2) + "\n") + print(f"Wrote {out} " + f"({len(spec['parameter_values'])} parameter values)") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/stoch_two_period_midhorizon_nreal0.json b/tests/fixtures/stoch_two_period_midhorizon_nreal0.json new file mode 100644 index 000000000..bd1a1e4a9 --- /dev/null +++ b/tests/fixtures/stoch_two_period_midhorizon_nreal0.json @@ -0,0 +1,6288 @@ +{ + "entity_classes": [ + [ + "commodity", + [], + "Stuff that is being processed", + 281351265055225, + true + ], + [ + "connection", + [], + null, + 280741963428706, + false + ], + [ + "constraint", + [], + null, + 281107035649330, + true + ], + [ + "flowGroup", + [], + null, + 143488754774337, + true + ], + [ + "group", + [], + null, + 280655732797761, + true + ], + [ + "model", + [], + "Settings for a model instance", + 280378317271475, + true + ], + [ + "node", + [], + null, + 280655732797997, + false + ], + [ + "profile", + [], + null, + 280376907921198, + true + ], + [ + "reserve", + [], + "Categories for the reservation of capacity", + 281473533932379, + true + ], + [ + "solve", + [], + null, + 280378317271532, + true + ], + [ + "timeline", + [], + null, + 280378317271144, + true + ], + [ + "timeset", + [], + null, + 280378317272400, + true + ], + [ + "unit", + [], + null, + 281107035648629, + false + ], + [ + "upDown", + [], + null, + 280378317271864, + true + ], + [ + "commodity__node", + [ + "commodity", + "node" + ], + "Properties of commodities in particular nodes", + 281054070108939, + true + ], + [ + "connection__node", + [ + "connection", + "node" + ], + null, + 280743406203659, + true + ], + [ + "connection__profile", + [ + "connection", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "group__connection", + [ + "group", + "connection" + ], + null, + null, + true + ], + [ + "group__node", + [ + "group", + "node" + ], + null, + null, + true + ], + [ + "group__unit", + [ + "group", + "unit" + ], + null, + null, + true + ], + [ + "node__profile", + [ + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "unit__inputNode", + [ + "unit", + "node" + ], + null, + 280378317271818, + true + ], + [ + "unit__outputNode", + [ + "unit", + "node" + ], + null, + 281473533932299, + true + ], + [ + "connection__node__node", + [ + "connection", + "node", + "node" + ], + null, + 280743406203746, + true + ], + [ + "flowGroup__connection__node", + [ + "flowGroup", + "connection", + "node" + ], + null, + null, + true + ], + [ + "flowGroup__unit__node", + [ + "flowGroup", + "unit", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__group", + [ + "reserve", + "upDown", + "group" + ], + null, + null, + true + ], + [ + "unit__node__profile", + [ + "unit", + "node", + "profile" + ], + null, + 280798025740550, + true + ], + [ + "reserve__upDown__connection__node", + [ + "reserve", + "upDown", + "connection", + "node" + ], + null, + null, + true + ], + [ + "reserve__upDown__unit__node", + [ + "reserve", + "upDown", + "unit", + "node" + ], + null, + null, + true + ] + ], + "parameter_value_lists": [ + [ + "capacity_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "capacity_margin_methods", + [ + "Im1hbnVhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InBlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3BlcmlvZF90b3RhbCI=", + "str" + ] + ], + [ + "co2_methods", + [ + "InByaWNlX3RvdGFsIg==", + "str" + ] + ], + [ + "co2_methods", + [ + "InRvdGFsIg==", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im1pbl9sb2FkX2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "conversion_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "conversion_methods", + [ + "ImNvbnN0YW50X2VmZmljaWVuY3ki", + "str" + ] + ], + [ + "decomposition_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_methods", + [ + "ImJlbmRlcnNfcmVnaW9uYWwi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "decomposition_schemes", + [ + "ImJlbmRlcnMi", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19tdWx0aXBsaWVyIg==", + "str" + ] + ], + [ + "energy_margin_methods", + [ + "ImluZmxvd19hZGRlciI=", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImRpc3BhdGNoX3Bsb3RzX29ubHki", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "InN0YW5kYWxvbmVfYWdncmVnYXRvcl9vbmx5Ig==", + "str" + ] + ], + [ + "flow_aggregator_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "has_DC", + [ + "InllcyI=", + "str" + ] + ], + [ + "has_invest", + [ + "InllcyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "Im5vX2luZmxvdyI=", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX2luX3Byb3BvcnRpb24i", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9hbmRfcGVha19mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InNjYWxlX3RvX2FubnVhbF9mbG93Ig==", + "str" + ] + ], + [ + "inflow_methods", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "Im5vdF9hbGxvd2VkIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImN1bXVsYXRpdmVfbGltaXRzIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfbm9fbGltaXQi", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfcGVyaW9kX3RvdGFsIg==", + "str" + ] + ], + [ + "invest_methods", + [ + "ImludmVzdF9yZXRpcmVfdG90YWwi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV90b3RhbCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9ub19saW1pdCI=", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2Qi", + "str" + ] + ], + [ + "invest_methods", + [ + "InJldGlyZV9wZXJpb2RfdG90YWwi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "Im5vX2ludmVzdG1lbnQi", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2F1dG9tYXRpYyI=", + "str" + ] + ], + [ + "lifetime_methods", + [ + "InJlaW52ZXN0X2Nob2ljZSI=", + "str" + ] + ], + [ + "load_share_type", + [ + "Im5vIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "load_share_type", + [ + "ImluZmxvd193ZWlnaHRlZCI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl91cHRpbWUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im1pbl9kb3dudGltZSI=", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "minimum_time_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ] + ], + [ + "node_type", + [ + "ImJhbGFuY2Vfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "node_type", + [ + "ImNvbW1vZGl0eSI=", + "str" + ] + ], + [ + "node_type", + [ + "InN0b3JhZ2Ui", + "str" + ] + ], + [ + "optional_output", + [ + "Im5vIg==", + "str" + ] + ], + [ + "optional_output", + [ + "InllcyI=", + "str" + ] + ], + [ + "penalty_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "penalty_methods", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlIg==", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9hbm51YWwi", + "str" + ] + ], + [ + "price_method", + [ + "InByaWNlX2xhZGRlcl9jdW11bGF0aXZlIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "ImZpeGVkIg==", + "str" + ] + ], + [ + "profile_methods", + [ + "Imxvd2VyX2xpbWl0Ig==", + "str" + ] + ], + [ + "profile_methods", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ] + ], + [ + "ramp_methods", + [ + "ImJvdGgi", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfY29zdCI=", + "str" + ] + ], + [ + "ramp_methods", + [ + "InJhbXBfbGltaXQi", + "str" + ] + ], + [ + "reserve_methods", + [ + "Im5vX3Jlc2VydmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImFsbCI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImR5bmFtaWNfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "ImxhcmdlX2ZhaWx1cmVfb25seSI=", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2R5bmFtaWMi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfYW5kX2xhcmdlX2ZhaWx1cmUi", + "str" + ] + ], + [ + "reserve_methods", + [ + "InRpbWVzZXJpZXNfb25seSI=", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImZ1bGxfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kIg==", + "str" + ] + ], + [ + "results", + [ + "ImxpbWl0ZWRfcGVyaW9kX3RpbWUi", + "str" + ] + ], + [ + "results", + [ + "InN1bW1hcnlfb25seSI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "Im9mZiI=", + "str" + ] + ], + [ + "scaling_modes", + [ + "InNvbHZlcl9vbmx5Ig==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImJhc2ljIg==", + "str" + ] + ], + [ + "scaling_modes", + [ + "ImZ1bGwi", + "str" + ] + ], + [ + "senses", + [ + "ImVxdWFsIg==", + "str" + ] + ], + [ + "senses", + [ + "ImdyZWF0ZXJfdGhhbiI=", + "str" + ] + ], + [ + "senses", + [ + "Imxlc3NfdGhhbiI=", + "str" + ] + ], + [ + "solve_mode", + [ + "InJvbGxpbmdfd2luZG93Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zaG90Ig==", + "str" + ] + ], + [ + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImNvcHQi", + "str" + ] + ], + [ + "solvers", + [ + "ImNwbGV4Ig==", + "str" + ] + ], + [ + "solvers", + [ + "Imd1cm9iaSI=", + "str" + ] + ], + [ + "solvers", + [ + "ImhpZ2hzIg==", + "str" + ] + ], + [ + "solvers", + [ + "InhwcmVzcyI=", + "str" + ] + ], + [ + "startup_methods", + [ + "Im5vX3N0YXJ0dXAi", + "str" + ] + ], + [ + "startup_methods", + [ + "ImJpbmFyeSI=", + "str" + ] + ], + [ + "startup_methods", + [ + "ImxpbmVhciI=", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5Ig==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfZm9yd2FyZF9vbmx5X2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfaW50cmFwZXJpb2RfYmxvY2tzIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZCI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3BlcmlvZF9ibGVuZGVkX3dlaWdodHMi", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlIg==", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3NvbHZlX2JsZW5kZWRfd2VpZ2h0cyI=", + "str" + ] + ], + [ + "storage_binding_methods", + [ + "ImJpbmRfd2l0aGluX3RpbWVibG9jayI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9wcmljZSI=", + "str" + ] + ], + [ + "storage_nested_fix_method", + [ + "ImZpeF9xdWFudGl0eSI=", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "ImZyZWUi", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfcHJpY2Ui", + "str" + ] + ], + [ + "storage_solve_horizon_methods", + [ + "InVzZV9yZWZlcmVuY2VfdmFsdWUi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9lbmQi", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9ub3RoaW5nIg==", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydCI=", + "str" + ] + ], + [ + "storage_start_end_methods", + [ + "ImZpeF9zdGFydF9lbmQi", + "str" + ] + ], + [ + "transfer_methods", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "Im5vX2xvc3Nlc19ub192YXJpYWJsZV9jb3N0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImRjX3Bvd2VyX2Zsb3dfd2l0aF9hbmdsZXMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "ImV4YWN0Ig==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InJlZ3VsYXIi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVuaWRpcmVjdGlvbmFsIg==", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ] + ], + [ + "transfer_methods_group", + [ + "InZhcmlhYmxlX2Nvc3Rfb25seSI=", + "str" + ] + ], + [ + "yes_no", + [ + "Im5vIg==", + "str" + ] + ], + [ + "yes_no", + [ + "InllcyI=", + "str" + ] + ] + ], + "parameter_groups": [ + [ + "basics", + "6fa8c7", + 10 + ], + [ + "constraint", + "a36784", + 70 + ], + [ + "emission", + "8cbb8a", + 45 + ], + [ + "flow_limit", + "d9bf96", + 55 + ], + [ + "investment", + "7fb095", + 20 + ], + [ + "model", + "a3a3a3", + 80 + ], + [ + "network", + "d9a8a0", + 50 + ], + [ + "output", + "9ac2d1", + 95 + ], + [ + "reserve", + "7aaeb0", + 40 + ], + [ + "retirement", + "a3b08c", + 25 + ], + [ + "solve_advanced", + "b56f6f", + 87 + ], + [ + "solve_basics", + "8c94b0", + 85 + ], + [ + "storage", + "a598c7", + 30 + ], + [ + "tech_advanced", + "d9925c", + 35 + ], + [ + "timeline", + "c2b870", + 90 + ] + ], + "parameter_definitions": [ + [ + "commodity", + "co2_content", + [ + "bnVsbA==", + null + ], + null, + "[CO2 ton per MWh] Constant.", + "emission" + ], + [ + "commodity", + "price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh or other unit] Price of the commodity. Constant, period or time.", + "basics" + ], + [ + "commodity", + "price_ladder_annual", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_annual'. Two forms accepted: 2d map with rows 'tier,price,quantity' applies the same per-year limit every period; 3d map with rows 'period,tier,price,quantity' varies the limit per period. 1-based integer tier. quantity=inf marks an unbounded tail tier.", + "basics" + ], + [ + "commodity", + "price_ladder_cumulative", + [ + null, + null + ], + null, + "Stepped supply curve for price_method='price_ladder_cumulative'. 2d map with rows 'tier,price,quantity' \u2014 one row per tier, giving the tier's unit price and its cumulative quantity cap. 1-based integer tier index. quantity=inf marks an unbounded tail tier. Period-agnostic \u2014 the cap is a single total across the full model horizon.", + "basics" + ], + [ + "commodity", + "price_method", + [ + "InByaWNlIg==", + "str" + ], + "price_method", + "How the commodity's price enters the LP. 'price' = scalar or time-series price x flow (current behaviour); 'price_ladder_annual' = stepped supply curve with a per-year quantity cap per tier; 'price_ladder_cumulative' = stepped supply curve with a total-model quantity cap per tier (handoff-carried across rolling solves).", + "basics" + ], + [ + "commodity", + "unitsize", + [ + null, + null + ], + null, + "Per-commodity scaling coefficient applied to the v_trade tier variable when the commodity uses the price-ladder feature (gated by commodity.price_method = price_ladder_annual or price_ladder_cumulative). When set, v_trade is expressed in user-MWh divided by this value; pick the unitsize so the largest tier quantity sits at O(10) in the scaled LP. The coefficient multiplies v_trade in the commodity_ladder_balance LHS, the per-tier cap LHS, and the per-tier objective term. When absent (the default), the price-ladder consumer substitutes 1.0 internally so v_trade is in user-MWh (identity scaling). Ignored entirely when commodity.price_method is not a price_ladder_* value.", + "basics" + ], + [ + "connection", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for connection flows. Constant or time.", + "basics" + ], + [ + "connection", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "connection", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "connection", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input node and the output node - works only with one-way connections (or units). Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "connection", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "connection", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor, typically between 0-1] Efficiency of a connection. Constant or time.", + "basics" + ], + [ + "connection", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "connection", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "connection", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost for new 'virtual' capacity. Constant or period.", + "investment" + ], + [ + "connection", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "connection", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "connection", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "connection", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "connection", + "is_DC", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the connection is DC (the flow will not be counted as synchronous if there is a non_synchronous_limit). Default false.", + "network" + ], + [ + "connection", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "connection", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "connection", + "other_operational_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Other operational variable cost for trasferring over the connection. Constant, Period or time.", + "basics" + ], + [ + "connection", + "reactance", + [ + null, + null + ], + null, + "[p.u.] Per-unit reactance of the transmission line. Used for DC power flow when the connection is within a nodeGroup that has transfer_method set to dc_power_flow_with_angles. The susceptance used in the flow equation is base_MVA / reactance.", + "network" + ], + [ + "connection", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "connection", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "connection", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value for retiring capacity. Constant or period.", + "retirement" + ], + [ + "connection", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "connection", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method", + "tech_advanced" + ], + [ + "connection", + "transfer_method", + [ + null, + null + ], + "transfer_methods", + "Choice of transfer method. Options: regular (default), no_losses_no_variable_cost, variable_cost_only, exact, unidirectional. 'unidirectional' restricts flow to source \u2192 sink only (single non-negative variable); 'regular'/'exact'/'variable_cost_only' are bidirectional two-variable variants; 'no_losses_no_variable_cost' is a single signed variable with no losses and no VOM.", + "basics" + ], + [ + "connection", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of single connection - used for integer (lumped) investments.", + "basics" + ], + [ + "connection__node", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow from the connection to a node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "connection__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "constraint", + "constant", + [ + "MA==", + "float" + ], + null, + "A constant offset for a user constraint (typically zero). The constant will be on the right side of the equation.", + "constraint" + ], + [ + "constraint", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "constraint" + ], + [ + "constraint", + "sense", + [ + "ImVxdWFsIg==", + "str" + ], + "senses", + "The sense of the constraint ('greater_than', 'less_than' or 'equal').", + "constraint" + ], + [ + "flowGroup", + "flow_aggregator", + [ + "Im5vbmUi", + "str" + ], + "flow_aggregator_methods", + "Choice of flow-aggregation method for this flowGroup: 'none' (used for flow limits only, no aggregated output), 'dispatch_plots_only' (used as an aggregated flowGroup within 'print_dispatch' group of nodes - do not include one flow in multiple flow aggregators to avoid double counting), 'standalone_aggregator_only' (just make time series available for manual output processing / simple plots), 'both'.", + "output" + ], + [ + "flowGroup", + "max_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Maximum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "max_instant_flow", + [ + null, + null + ], + null, + "[MW] Maximum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "flowGroup", + "min_cumulative_flow", + [ + null, + null + ], + null, + "[MW] Minimum average flow, which limits the cumulative flow for a group of connection_nodes and/or unit_nodes. The average value is multiplied by the model duration to get the cumulative limit (e.g. by 8760 if a single year is modelled). Applied for each solve. Constant or period.", + "flow_limit" + ], + [ + "flowGroup", + "min_instant_flow", + [ + null, + null + ], + null, + "[MW] Minimum instantenous flow for the aggregated flow of all group members. Constant, period, time or period+time.", + "flow_limit" + ], + [ + "group", + "base_MVA", + [ + null, + null + ], + null, + "[MVA] Base power for the per-unit system used in DC power flow. Default 100. susceptance = base_MVA / reactance_pu.", + "network" + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + [ + null, + null + ], + null, + "[MW] Small pre-existing capacity assigned to investment candidate connections in DC power flow groups that have zero existing capacity. This avoids the need for big-M / MIP constraints by ensuring the angle constraint is always active. Default 0.1 MW. The value should be small enough to not affect results but large enough for numerical stability.", + "network" + ], + [ + "group", + "capacity_margin", + [ + "bnVsbA==", + null + ], + null, + "[MW] How much capacity a node group is required to have in addition to the peak net load in the investment time series. Used only by the investment mode. Constant or period.", + "investment" + ], + [ + "group", + "capacity_margin_method", + [ + "Im5vbmUi", + "str" + ], + "capacity_margin_methods", + "Whether the node group enforces a capacity margin in the investment mode. 'none' (default) = no constraint. 'manual' requires the group to have 'capacity_margin' MW of capacity above the peak net load. Replaces the former has_capacity_margin yes/no flag; reserved for a future 'automatic' mode.", + "investment" + ], + [ + "group", + "co2_max_period", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Annualized maximum limit for emitted CO2 in each period.", + "emission" + ], + [ + "group", + "co2_max_total", + [ + "bnVsbA==", + null + ], + null, + "[tCO2] Maximum limit for emitted CO2 in the whole solve.", + "emission" + ], + [ + "group", + "co2_method", + [ + null, + null + ], + "co2_methods", + "Choice of the CO2 method: none, price, period, total, price_period, price_total, period_total, price_period_total", + "emission" + ], + [ + "group", + "co2_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/ton] CO2 price for a group of nodes. Constant, period or time.", + "emission" + ], + [ + "group", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Maximum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW or MWh] Minimum cumulative capacity for a group of entities (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "group", + "decomposition_method", + [ + "Im5vbmUi", + "str" + ], + "decomposition_methods", + "Decomposition strategy to apply to this group. Currently supported: 'none' (no decomposition \u2014 default), 'benders_regional' (group is solved as an independent region in the Benders master/subproblem decomposition, with shared-commodity trade coupling handled by the master).", + "solve_advanced" + ], + [ + "group", + "has_inertia", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has an inertia constraint active.\n", + "network" + ], + [ + "group", + "has_non_synchronous", + [ + "bnVsbA==", + null + ], + "yes_no", + "A flag whether the group of nodes has the non-synchronous share constraint active.", + "network" + ], + [ + "group", + "include_stochastics", + [ + null, + null + ], + "yes_no", + "Includes the stochastic branches to be used for the nodes/units/connections in this group", + "solve_advanced" + ], + [ + "group", + "inertia_limit", + [ + "bnVsbA==", + null + ], + null, + "[MWs] Minimum for synchronous inertia in the group of nodes. Constant or period.", + "network" + ], + [ + "group", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Maximum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.\r\n", + "investment" + ], + [ + "group", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment per period to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Period.", + "investment" + ], + [ + "group", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW or MWh] Minimum investment to the virtual capacity of a group of units or to the storage capacity of a group of nodes. Total over all solves. Constant.", + "investment" + ], + [ + "group", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Members of this group operate at this step duration. Overrides the solve-level new_stepduration for these entities. Used for multi-resolution models where some nodes (e.g. fuel markets) need coarser dispatch resolution than others (e.g. power systems).", + "solve_advanced" + ], + [ + "group", + "non_synchronous_limit", + [ + "bnVsbA==", + null + ], + null, + "[share, e.g. 0.8 means 80%] The maximum share of non-synchronous generation in the node group. Constant or period.", + "network" + ], + [ + "group", + "penalty_capacity_margin", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/kW] Penalty for violating the capacity margin constraint. Uses operational discounting (not annualized over lifetime like investment costs), so the value is not directly comparable to annualized investment costs. Constant or period.", + "investment" + ], + [ + "group", + "penalty_inertia", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWs] Penalty for violating the inertia constraint. Cost scales with the duration of the violation. Constant or period.", + "network" + ], + [ + "group", + "penalty_non_synchronous", + [ + "NTAwMA==", + "float" + ], + null, + "[CUR/MWh] Penalty for violating the non synchronous constraint. Constant or period.", + "network" + ], + [ + "group", + "print_dispatch", + [ + null, + null + ], + "yes_no", + "Combines unit/connection flows, flowGroup aggregated flows, and other balance elements (slack, demand, internal_losses, etc.) into a set of timeseries and a preconfigured plot per group.", + "output" + ], + [ + "group", + "print_indicators", + [ + null, + null + ], + "yes_no", + "Output indicators for the set of nodes in the group (Loss of Load, VRE generation, etc.).", + "output" + ], + [ + "group", + "reference_node", + [ + null, + null + ], + null, + "Name of the reference bus node (angle fixed to zero) for DC power flow. Optional \u2014 if not specified, automatically selected as the node with the largest existing capacity in each connected component of the DC power flow network.", + "network" + ], + [ + "group", + "share_loss_of_load", + [ + null, + null + ], + "load_share_type", + "Force the upward slack of the nodes in this group to be equal or inflow (demand) weighted", + "flow_limit" + ], + [ + "group", + "transfer_method", + [ + "InVzZV9jb25uZWN0aW9uX3RyYW5zZmVyX21ldGhvZHMi", + "str" + ], + "transfer_methods_group", + "Override transfer_method for all connections within this nodeGroup. Options: use_connection_transfer_methods (default, no override), no_losses_no_variable_cost, regular, exact, variable_cost_only, unidirectional, dc_power_flow_with_angles. Setting 'unidirectional' gates every member connection to one-way flow (source \u2192 sink only). Setting 'dc_power_flow_with_angles' uses B-theta DC power flow (requires reactance on connections).", + "network" + ], + [ + "group", + "use_for_representative_periods", + [ + "Im5vIg==", + "str" + ], + "yes_no", + "A flag whether this group of nodes is an aggregation unit for net-load representative-period selection. When 'yes', the representative-periods preprocessor aggregates the member nodes' net load (demand minus available VRE) into a single regional signal used for clustering; when 'no' (default) the group is ignored by that selection. If no group carries the flag, the selection falls back to per-node net-load signals.", + "solve_advanced" + ], + [ + "model", + "inflation_offset_investment", + [ + "MC4w", + "float" + ], + null, + "[years] Offset for when investment costs occur within a year. Default 0 (beginning of year).", + "model" + ], + [ + "model", + "inflation_offset_operations", + [ + "MC41", + "float" + ], + null, + "[years] Offset for when operational costs occur within a year. Default 0.5 (middle of year).", + "model" + ], + [ + "model", + "inflation_rate", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.02 for 2%] Model-wide inflation rate applied to all future costs. When inputs are in real (constant-price) terms, set to 0. When inputs are in nominal terms, set to expected inflation. Default: 0 (real inputs).", + "model" + ], + [ + "model", + "max_flow_for_unconstrained_variables", + [ + "MTAwMDAwMC4w", + "float" + ], + null, + "[MW] Upper bound assigned to LP variables that have no other cap (invest_no_limit capacity; flows through edges whose capacity_max_coeff is zero; infinite-capacity commodity tiers). Keep large enough not to constrain the physical solution but small enough to avoid numerical issues (default 1,000,000).", + "model" + ], + [ + "model", + "output_horizon", + [ + null, + null + ], + "yes_no", + "Outputs the flows in the horizons. Used for testing the model.", + "output" + ], + [ + "model", + "periods_available", + [ + null, + null + ], + null, + "(Optional) Array of periods available for the model. Periods that are in the data, but are not in period_timeset.", + "model" + ], + [ + "model", + "small_number_threshold", + [ + "MC4wMDAx", + "float" + ], + null, + "Coefficients and right-hand-side terms whose absolute value is below this are treated as 0 when building the optimisation problem. Reduces the numerical range (conditioning) of the LP. Default 0.0001.", + "model" + ], + [ + "model", + "solves", + [ + "bnVsbA==", + null + ], + null, + "Sequence of solves in the model. Array.", + "model" + ], + [ + "model", + "version", + [ + "NzU=", + "float" + ], + null, + "Contains database version information.", + "model" + ], + [ + "node", + "annual_flow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Annual flow in energy units (always positive, the sign of inflow defines in/out). Inflow time series can be scaled to match annual flow. By default, there is no scaling of inflow. Constant or period.", + "basics" + ], + [ + "node", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for storage. Constant or time.", + "basics" + ], + [ + "node", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "node", + "constraint_state_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "node", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MWh] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MWh] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "node", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_adder", + [ + "MC4w", + "float" + ], + null, + "[MWh] Extra demand (negative inflow) added to this node in the investment solve only, when energy_margin_method = 'inflow_adder'. Raises adequacy headroom without changing dispatch demand. 0 = off. Constant or period.", + "investment" + ], + [ + "node", + "energy_margin_method", + [ + "Im5vbmUi", + "str" + ], + "energy_margin_methods", + "How the node's investment-stage energy margin is applied. 'none' (default) = off, no scaling. 'inflow_multiplier' multiplies this node's inflow by 'energy_margin_multiplier' in the solve that carries investment periods only (dispatch solves are unaffected). 'inflow_adder' instead adds 'energy_margin_adder' MWh of extra demand (negative inflow) in that same investment solve. Both offset representative-period VRE optimism that would otherwise cause underinvestment.", + "investment" + ], + [ + "node", + "energy_margin_multiplier", + [ + "MS4w", + "float" + ], + null, + "[factor] Multiplier applied to this node's inflow in the investment solve only, when energy_margin_method = 'inflow_multiplier'. Offsets representative-period VRE optimism (rep-period average VRE availability exceeds the annual average). 1 = off. e.g. 1.1 builds ~10% more capacity to serve the true annual demand under the true (lower) annual VRE. Constant or period.", + "investment" + ], + [ + "node", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Existing storage capacity. Constant or Period", + "basics" + ], + [ + "node", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Annual fixed cost for storage. Constant or period.", + "investment" + ], + [ + "node", + "inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Inflow into the node (negative is outflow). Constant or time.", + "basics" + ], + [ + "node", + "inflow_method", + [ + "InVzZV9vcmlnaW5hbCI=", + "str" + ], + "inflow_methods", + "Choice how to treat inflow time series. Empty defaults to 'use_original', which does not scale the time series. 'no_inflow' ignores the inflow time series. 'scale_to_annual_flow' scales the inflow time series by a single factor (shape preserved) so its annual energy equals 'annual_flow'. The annualisation uses each timestep's share of the year: for a full or evenly sampled timeline this is 8760/'hours_in_solve' (every step weighted equally); for a representative timeset with 'timeset_weights', each timestep is weighted by its declared share of the year, so a biased sample of timesteps still annualises correctly. The same weighting is used for energy reporting and operating-cost accounting, so the energy the model is scaled to serve, reports, and costs all coincide. If 'timeset_weights' do not reflect the true representativeness of your selected timesteps, the scaling factor - and therefore the absolute demand level - shifts accordingly; see the per-node 'f' diagnostic (inflow_scaling_diagnostics.csv). 'scale_in_proprotion' calculates a scaling factor by dividing 'annual_flow' with the sum of time series inflow (after it has been annualized using 'timeline_duration_in_years'). (With a representative timeset, the annual-energy match uses 'timeset_weights' as above.) 'scale_to_annual_and_peak_flow' scales the time series to match the 'annual_flow' target while transforming the time series to match the highest load with the 'peak_inflow'. (With a representative timeset, the annual-energy match uses 'timeset_weights' as above; the peak match stays unweighted.)", + "basics" + ], + [ + "node", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Investment cost for new storage capacity. Constant or period.", + "investment" + ], + [ + "node", + "invest_forced", + [ + null, + null + ], + null, + "[MWh] Forces the model to invest exactly this amount of new storage capacity, overriding the investment optimisation (equivalent to setting invest_min and invest_max equal). Constant or period.", + "investment" + ], + [ + "node", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: either not_allowed or then a combination of 1) investment or retirement and 2) investment limits per period, all periods (total) or no_limits.", + "investment" + ], + [ + "node", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum storage investment. Period.", + "investment" + ], + [ + "node", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "node", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "node", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "node", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "node_type", + "Role of this node in the LP. 'commodity' = price-exposed source/sink with no balance constraint (e.g. fuel imports, no storage); 'balance' = energy balance maintained every timestep (default); 'storage' = balance plus a state variable (battery, reservoir); 'balance_within_period' = balance aggregated over the whole period (e.g. an annual gas budget).", + "basics" + ], + [ + "node", + "peak_inflow", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Highest flow for scaling the inflow. Used only with inflow_method scale_to_annual_and_peak_flow. Constant or period.", + "basics" + ], + [ + "node", + "penalty_down", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for increasing consumption in the node (excess energy). Constant, Period or Time.", + "basics" + ], + [ + "node", + "penalty_method", + [ + "InJlZ3VsYXIi", + "str" + ], + "penalty_methods", + "Whether this node has balance penalty (unserved-energy / over-supply slack) variables. 'regular' (default) keeps the penalty_up / penalty_down slack variables that let the node balance be violated at a cost. 'off' removes those slack variables entirely, making the node balance a hard equality (the solve becomes infeasible if the node cannot balance). Use 'off' only if certain that it will not make the model infeasible (a real solution exists) - infeasible models can be very difficult to debug. At the same time, 'off' will reduce variables and can direct infeasibilities to other, more desirable, nodes.", + "basics" + ], + [ + "node", + "penalty_up", + [ + "MTAwMDA=", + "float" + ], + null, + "[CUR/MWh] Penalty cost for decreasing consumption in the node (energy not served). Constant, Period or Time.", + "basics" + ], + [ + "node", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Maximum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity. Period.", + "retirement" + ], + [ + "node", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Minimum retired storage capacity over all solves. Constant.", + "retirement" + ], + [ + "node", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kWh] Salvage value of the storage. Constant or period.", + "retirement" + ], + [ + "node", + "self_discharge_loss", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.01 means 1% every hour] Loss of stored energy over time. Constant or time.", + "storage" + ], + [ + "node", + "storage_binding_method", + [ + null, + null + ], + "storage_binding_methods", + "Choice how the storage state will be maintained over discontinuous timelines. Seven cycle-scope methods (the state-continuity family): 'bind_within_timeblock' cycles state within each timeblock (cycle closes at block boundaries); 'bind_within_period' cycles within each FlexTool period and chains blocks inside the period; 'bind_within_solve' cycles across the whole solve horizon; 'bind_forward_only' (default) chains state forward across the solve with no end-to-start closure; 'bind_within_solve_blended_weights' is the representative-period variant of bind_within_solve (RP weighting + solve-level cycle closure); 'bind_within_period_blended_weights' is the RP variant of bind_within_period (per-period RP weighting, each period closes independently); 'bind_forward_only_blended_weights' is the RP variant of bind_forward_only (RP weighting, no cycle closure). One additional value, 'bind_intraperiod_blocks', is structurally an aggregation method rather than a cycle-scope method: state is held constant within each block and the block-total flow is balanced at the boundary. Silent-degrade behaviour: any '*_blended_weights' method on a node in a solve whose active timeset has no representative_period_weights is automatically downgraded to the corresponding non-RP variant for that solve, so the same storage entity can drive an RP investment solve and a chronological dispatch solve back-to-back. Separate parameters (e.g. 'storage_state_start') can force additional bindings. By default, storage start state is bound to 0.", + "storage" + ], + [ + "node", + "storage_nested_fix_method", + [ + "ImZpeF91c2FnZSI=", + "str" + ], + "storage_nested_fix_method", + "Used in nested solve sequences. Set this storage as a long term storage, which end state is passed to the lower level solves as a target. *Fix_price* requires `storage_state_reference_price`", + "storage" + ], + [ + "node", + "storage_solve_horizon_method", + [ + null, + null + ], + "storage_solve_horizon_methods", + "Choice how to treat storage state at the end of time horizon of each solve. 'free' lets the model choose the end state. 'use_reference_value' forces the end state to the specific value set in 'storage_state_reference_value'. 'use_reference_price' gives the storage state a price in the objective function based on the 'storage_state_reference_price'. ", + "storage" + ], + [ + "node", + "storage_start_end_method", + [ + null, + null + ], + "storage_start_end_methods", + "Choice whether the start and end states of storage are fixed in the beginning and end of the whole model timeline (not between solves). Uses 'storage_state_start' and 'storage_state_end'. Options: 'fix_nothing', 'fix_start', 'fix_end', 'fix_start_end'. By default, storage start state is bound to 0 to avoid creation of free energy.", + "storage" + ], + [ + "node", + "storage_state_end", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at the end of the last model solve (overrides 'storage_state_end_reference'). Constant.", + "storage" + ], + [ + "node", + "storage_state_reference_price", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MWh] Price for the stored energy at the end of the solve horizon. Requires 'use_reference_price' in 'storage_solve_horizon_method'. Constant or period.", + "storage" + ], + [ + "node", + "storage_state_reference_value", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Relative state of storage at then end of each solve (can be overwritten in the next solve). Requires 'use_reference_value' in 'storage_solve_horizon_method'. Constant.", + "storage" + ], + [ + "node", + "storage_state_start", + [ + null, + null + ], + null, + "[0-1] Relative state of storage at the beginning of the first model solve (irrespective of when the model starts). Constant.", + "storage" + ], + [ + "node", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MWh] Size of a single storage unit - used for integer investments (lumped investments). If not given, assumed from the existing storage capacity.", + "basics" + ], + [ + "node__profile", + "profile_method", + [ + "bnVsbA==", + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "profile", + "profile", + [ + "bnVsbA==", + null + ], + null, + "[factor, typically between 0-1]. Availability time series for a fluctuating resource like wind power. Time.", + "basics" + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by the flow from the connection to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each connection using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "max_share", + [ + null, + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__connection__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + [ + null, + null + ], + null, + "[factor] The reserve is increased by the sum of demands from the group members multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "penalty_reserve", + [ + null, + null + ], + null, + "[CUR/MW] Penalty for violating a reserve constraint. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reservation", + [ + null, + null + ], + null, + "[MW] Amount of reserve to be reserved. Constant or time.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], + [ + "reserve__upDown__group", + "reserve_method", + [ + "Im5vX3Jlc2VydmUi", + "str" + ], + "reserve_methods", + "Choice of reserve method: no_reserve, timeseries_only, dynamic_only, large_failure_only, timeseries_and_dynamic, timeseries_and_large_failure, dynamic_and_large_failure, all", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + [ + "bnVsbA==", + null + ], + null, + "[factor] The reserve requirement is increased by generation from the unit to the node multiplied by this ratio. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + [ + "InllcyI=", + "str" + ], + "yes_no", + "Whether the entity is enabled. Set to 'no' to disable without deleting the entity. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + [ + null, + null + ], + null, + "[factor] Each unit using the N-1 failure method will have a separate constraint to require sufficient reserve to cover a failure of the unit generation (multiplied by this ratio). Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "max_share", + [ + "bnVsbA==", + null + ], + null, + "[factor] Maximum ratio for the transfer of reserve to this node. Constant.", + "reserve" + ], + [ + "reserve__upDown__unit__node", + "reliability", + [ + "MQ==", + "float" + ], + null, + "[factor] The share of the reservation that is counted to reserves (sometimes reserve sources are not fully trusted). Constant.", + "reserve" + ], + [ + "solve", + "benders_in_out_weight", + [ + "MC4w", + "float" + ], + null, + "Benders decomposition: in-out separation stabilization weight (lambda) in f_sep = lambda*centre + (1-lambda)*f_out. 0.0 (default) = off (exact Benders); values in (0,1) turn it on, larger = more stabilization. Only used when decomposition = 'benders'. Default 0.0.", + "solve_advanced" + ], + [ + "solve", + "benders_max_iter", + [ + "NTAuMA==", + "float" + ], + null, + "Benders decomposition: maximum number of outer (master/subproblem) iterations. Only used when decomposition = 'benders'. Default 50.", + "solve_advanced" + ], + [ + "solve", + "benders_tolerance", + [ + "MC4wMDE=", + "float" + ], + null, + "Benders decomposition: relative optimality-gap tolerance (best_UB - LB)/|best_UB| for convergence of the outer loop. Only used when decomposition = 'benders'. Default 0.001.", + "solve_advanced" + ], + [ + "solve", + "contains_solves", + [ + "bnVsbA==", + null + ], + null, + "Array of solves - used for nested solve sequencesArray of solves - used for nested solve sequences", + "solve_advanced" + ], + [ + "solve", + "decomposition", + [ + "Im5vbmUi", + "str" + ], + "decomposition_schemes", + "Decomposition scheme for this solve. 'none' (default) solves monolithically. 'benders' runs the solve through the Benders region-decomposition driver (a master trade layer coordinating per-region recourse subproblems via appended optimality cuts, producing a valid lower bound) over the groups whose group.decomposition_method is 'benders_regional' (requires at least two such region groups). The choice is per solve, so an investment solve can decompose while a later dispatch solve in the same chain stays monolithic.", + "solve_advanced" + ], + [ + "solve", + "discount_years", + [ + "bnVsbA==", + null + ], + null, + "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", + null + ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], + [ + "solve", + "fix_storage_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where the storage_values are fixed when the node has storage_include_solve_fix_method is set", + "solve_advanced" + ], + [ + "solve", + "invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods where investments are allowed.", + "solve_basics" + ], + [ + "solve", + "new_stepduration", + [ + "bnVsbA==", + null + ], + null, + "Hours. Creates a new `timeline` from the old for this `solve` with this timestep duration. The new timeline will sum or average the other timeseries data like `profile` and `inflow` for the new timesteps.", + "timeline" + ], + [ + "solve", + "period_timeset", + [ + "bnVsbA==", + null + ], + null, + "Map of periods with associated timesets that will be included in the solve. Index: period name, value: timeset name.", + "solve_basics" + ], + [ + "solve", + "realized_invest_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of the periods that will realize the investment decisions. If not used when the invest_periods exist. The realized_periods are used to realize invests as well", + "solve_advanced" + ], + [ + "solve", + "realized_periods", + [ + "bnVsbA==", + null + ], + null, + "Array of periods that will be realized in the solve.", + "solve_basics" + ], + [ + "solve", + "rolling_duration", + [ + "bnVsbA==", + null + ], + null, + "Hours (Optional). Duration of rolling, if not stated, assumed to be the whole timeline of the solve", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_horizon", + [ + "bnVsbA==", + null + ], + null, + "Hours (Required if rolling_window solve). How long into the future the roll sees", + "solve_advanced" + ], + [ + "solve", + "rolling_solve_jump", + [ + "bnVsbA==", + null + ], + null, + "Hours, (Required if rolling_window solve). Interval between the start points of the rolls. Should be smaller than the horizon", + "solve_advanced" + ], + [ + "solve", + "scaling", + [ + "ImZ1bGwi", + "str" + ], + "scaling_modes", + "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", + "solve_advanced" + ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], + [ + "solve", + "solve_mode", + [ + null, + null + ], + "solve_mode", + "A single_solve or rolling_window for a set of rolling optimisation windows solved in a sequence.", + "solve_basics" + ], + [ + "solve", + "solver", + [ + "ImhpZ2hzIg==", + "str" + ], + "solvers", + "Solver to use for this solve. One of polar-high's available_solvers ('highs', 'gurobi', 'cplex', 'xpress', 'copt'). HiGHS is the bundled default; commercial solvers require a separate install and license.", + "solve_advanced" + ], + [ + "solve", + "solver_arguments", + [ + "bnVsbA==", + null + ], + null, + "Map of HiGHS solver options (option name -> value), layered on top of the floor values in solver_config/highs.opt and below any CLI overrides (e.g. --solver-time-limit). Use this for ad-hoc HiGHS knobs that do not have a dedicated FlexTool parameter (solver_mip_gap, solver_precommand).", + "solve_advanced" + ], + [ + "solve", + "solver_mip_gap", + [ + "bnVsbA==", + null + ], + null, + "Relative MIP optimality gap at which the solver may stop early. Normalised across solvers by polar-high. None = solver default.", + "solve_advanced" + ], + [ + "solve", + "solver_precommand", + [ + "bnVsbA==", + null + ], + null, + "Additional command to execute before calling the solver. Can be used to e.g. reserve a floating license for a commercial solver.", + "solve_advanced" + ], + [ + "solve", + "stochastic_branches", + [ + "bnVsbA==", + null + ], + null, + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "solve_advanced" + ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], + [ + "solve", + "timeline_hole_multiplier", + [ + "MS4w", + "float" + ], + null, + "[unitless] Multiplier applied to the inverse-step-duration term in nodeBalance_eq and storage-binding constraints across timeline gaps (holes). Tunes how strongly state differences are penalised across discontinuities in the timeline. Default 1.0 mirrors the .mod default and matches pre-v44 Python-convention behaviour.", + "solve_advanced" + ], + [ + "solve", + "years_represented", + [ + "bnVsbA==", + null + ], + null, + "Map to indicate how many years the period represents before the next period in the solve. Used for discounting. Can be below one (multiple periods in one year). Index: period, value: years.", + "solve_basics" + ], + [ + "timeline", + "timestep_duration", + [ + "bnVsbA==", + null + ], + null, + "Map of time steps in the timeline. Index: time step name, value: time step duration in hours.", + "timeline" + ], + [ + "timeset", + "representative_period_weights", + [ + null, + null + ], + null, + "Representative-period weighting map (rank-2 nested Map: base period start timestep -> representative period start timestep -> weight). Created by the representative-periods preprocess tool; declares how much each representative period stands in for each base period. Its presence enables RP-aware storage-binding ('*_blended_weights'); its absence silently downgrades those methods to the non-RP behaviour. Mutually exclusive with 'timeset_weights' on the same timeset.", + "solve_advanced" + ], + [ + "timeset", + "timeline", + [ + null, + null + ], + null, + "The name of the timeline that the timeset uses. (String)", + "timeline" + ], + [ + "timeset", + "timeset_duration", + [ + "bnVsbA==", + null + ], + null, + "Index: name of the the timestep that starts the timeset, value: duration of the block in timesteps. Defines the *selection*: which timesteps are in the solve and how many (this sets 'hours_in_solve'). Representativeness - each timestep's share of the year - is set separately by 'timeset_weights'.", + "timeline" + ], + [ + "timeset", + "timeset_weights", + [ + null, + null + ], + null, + "Per-timestep weight map (index: timestep name, value: float) defining *representativeness*: each timestep's share of the year. Drives the annualisation of demand, energy, and cost alike (the inflow scaling, the annual energy outputs, and the operating-cost objective all use this same weight), so the energy the model is scaled to serve, reports, and costs all coincide. Use for non-RP models where timesteps represent unequal fractions of the year (e.g. seasonal yearsplit on a coarse timeslice structure). Weights are normalized per period to sum to 1 and then scaled by the number of active timesteps so that uniform input reproduces the default (weight = 1 per step). Must not be combined with representative_period_weights on the same timeset.", + "solve_advanced" + ], + [ + "unit", + "availability", + [ + "MS4w", + "float" + ], + null, + "[e.g. 0.9 means 90%] Fraction of capacity available for flows from/to the unit. For online units, the online variable is multiplied by the availability. Constant or time.", + "basics" + ], + [ + "unit", + "constraint_cumulative_pre_built_capacity_coeff", + [ + null, + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places the cumulative pre-built capacity at period d \u2014 data baseline plus every v_invest made in periods strictly BEFORE d, retirements ignored \u2014 on the left side of the user-defined constraint. Enables learning-effect and period-over-period growth limits. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "constraint_invested_capacity_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", + "constraint" + ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], + [ + "unit", + "conversion_method", + [ + null, + null + ], + "conversion_methods", + "Choice of conversion method.", + "basics" + ], + [ + "unit", + "cumulative_max_capacity", + [ + null, + null + ], + null, + "[MW] Maximum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "cumulative_min_capacity", + [ + null, + null + ], + null, + "[MW] Minimum cumulative capacity (considers existing, invested and retired capacity). Constant or period.", + "investment" + ], + [ + "unit", + "delay", + [ + null, + null + ], + null, + "[hours] A time delay between the input nodes and the output nodes. Either a constant indicating the time difference in hours or a map of time differences (index: time difference in hours, value: weight). Each weight indicates its share of the original flow and the weights should sum to 1. Requires that the time resolutions in the model are always integer multiples of these time differences.", + "tech_advanced" + ], + [ + "unit", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + null, + "[e.g. 0.05 equals 5%] Discount rate for investments (WACC). Reflects the financing cost and risk premium for this technology. When the model inflation_rate > 0, this should be a nominal rate. When inflation_rate = 0, this should be a real rate. Used to annualize investment costs over the lifetime. Constant or period.", + "investment" + ], + [ + "unit", + "efficiency", + [ + "MS4w", + "float" + ], + null, + "[factor] Efficiency of a unit. Constant or time.", + "basics" + ], + [ + "unit", + "efficiency_at_min_load", + [ + "bnVsbA==", + null + ], + null, + "[e.g. 0.4 means 40%] Efficiency of the unit at minimum load. Applies only if the unit has an online variable. Constant.", + "basics" + ], + [ + "unit", + "existing", + [ + "bnVsbA==", + null + ], + null, + "[MW] Existing capacity. Constant or Period", + "basics" + ], + [ + "unit", + "fixed_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Annual fixed cost. Constant or period.", + "investment" + ], + [ + "unit", + "invest_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Investment cost of the unit. Constant or period.", + "investment" + ], + [ + "unit", + "invest_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment. Period.", + "investment" + ], + [ + "unit", + "invest_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "invest_method", + [ + null, + null + ], + "invest_methods", + "Choice of investment method: not_allowed, invest and retire indicate availability of investment and retirement. no_limit removes all limits even if there are limiting values set. period uses values set in invest/retire_max/min_period. total uses values set in invest/retire_max/min_total.", + "investment" + ], + [ + "unit", + "invest_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment. Period.", + "investment" + ], + [ + "unit", + "invest_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum investment over all solves. Constant.", + "investment" + ], + [ + "unit", + "lifetime", + [ + "bnVsbA==", + null + ], + null, + "[years] Used to calculate annuity together with interest rate. Constant or period.", + "investment" + ], + [ + "unit", + "lifetime_method", + [ + null, + null + ], + "lifetime_methods", + "Choice how the investments behave after unit runs out of lifetime. Automatic reinvestment causes the model to keep the capacity until the end of model horizon and applies the annualized investment cost without further model choice. Choice of reinvestment removes the capacity at the end of the lifetime and the model needs to decide how much new capacity is to be built.", + "investment" + ], + [ + "unit", + "min_downtime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay offline after shutting down. Requires minimum_time_method set to 'min_downtime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "min_load", + [ + "bnVsbA==", + null + ], + null, + "[0-1] Minimum load of the unit. Applies only if the unit has an online variable. With linear startups, it is the share of capacity started up. Constant or time.", + "basics" + ], + [ + "unit", + "min_uptime", + [ + "bnVsbA==", + null + ], + null, + "[hours] Minimum time the unit must stay online after starting up. Requires minimum_time_method set to 'min_uptime' or 'both'. Constant.", + "tech_advanced" + ], + [ + "unit", + "minimum_time_method", + [ + "bnVsbA==", + null + ], + "minimum_time_methods", + "Choice between minimum up- and downtimes (none, min_downtime, min_uptime, both). Setting this to anything other than 'none' will activate online variables (at least linear) for the unit. Default: none (no minimum time constraints).", + "tech_advanced" + ], + [ + "unit", + "retire_max_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_max_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Maximum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "retire_min_period", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity. Period.", + "retirement" + ], + [ + "unit", + "retire_min_total", + [ + "bnVsbA==", + null + ], + null, + "[MW] Minimum retired capacity over all solves. Constant.", + "retirement" + ], + [ + "unit", + "salvage_value", + [ + "bnVsbA==", + null + ], + null, + "[CUR/kW] Salvage value of the unit. Constant or period.", + "retirement" + ], + [ + "unit", + "startup_cost", + [ + "bnVsbA==", + null + ], + null, + "[CUR/MW] Cost of starting up one MW of 'virtual' capacity. Constant.", + "tech_advanced" + ], + [ + "unit", + "startup_method", + [ + null, + null + ], + "startup_methods", + "Choice of startup method. Linear startup means that the unit can start partially (anything between 0 and full capacity) but will face startup cost as well as minimum load limit based on the capacity started up. Binary startup means that the unit is either off or fully on, but it is computationally more demanding than linearized startups.", + "tech_advanced" + ], + [ + "unit", + "virtual_unitsize", + [ + "bnVsbA==", + null + ], + null, + "[MW] Size of a single unit - used for integer investments (lumped investments), minimum loads and start-up costs. If not given, assumed from the existing unit capacity.", + "basics" + ], + [ + "unit__inputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__inputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + [ + "bnVsbA==", + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__inputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__inputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__inputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__inputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp method. 'ramp_limit' poses a limit on the speed of ramp. 'ramp_cost' poses a cost on ramping the flow (NOT FUNCTIONAL AS OF 19.3.2023).", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__inputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ], + [ + "unit__node__profile", + "profile_method", + [ + null, + null + ], + "profile_methods", + "Choice of profile method (upper_limit, lower_limit, fixed). Negative values also possible.\n", + "basics" + ], + [ + "unit__outputNode", + "capacity_max_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity available to this edge's upper cap (maxToSink / maxFromSource / ramp). For extraction CHP set to 1.0 on each output so each can reach full capacity when the other drops.", + "basics" + ], + [ + "unit__outputNode", + "capacity_min_coeff", + [ + "MS4w", + "float" + ], + null, + "[factor, default 1.0] Fraction of the unit's capacity imposed as a lower cap on this edge when online (combined multiplicatively with the unit-level min_load). Set to 0 to remove the lower cap on this edge (e.g. heat output of an extraction CHP that may drop to zero in pure-condensing mode).", + "basics" + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + [ + null, + null + ], + null, + "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the flow between unit and node in user-defined constraints. [flow x coefficient] will be on the left side of the equation.", + "constraint" + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + [ + "MQ==", + "float" + ], + null, + "[factor] Energy-unit conversion factor for this flow in the node balance and conversion_indirect equations. Value of 0 removes the edge from capacity / ramp / min-load constraints entirely (hydro-pass-through pattern).", + "basics" + ], + [ + "unit__outputNode", + "inertia_constant", + [ + null, + null + ], + null, + "[MWs/MW] Inertia constant for a synchronously connected unit to this node. Constant.", + "network" + ], + [ + "unit__outputNode", + "is_non_synchronous", + [ + null, + null + ], + null, + "Chooses whether the unit is synchronously connected to this node.", + "network" + ], + [ + "unit__outputNode", + "other_operational_cost", + [ + null, + null + ], + null, + "[CUR/MWh] Other operational variable cost for energy flows. Constant, Period or Time.", + "basics" + ], + [ + "unit__outputNode", + "ramp_cost", + [ + null, + null + ], + null, + "[CUR/MW] Cost of ramping the unit. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_method", + [ + null, + null + ], + "ramp_methods", + "Choice of ramp methods.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_down", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp down speed. Constant.", + "tech_advanced" + ], + [ + "unit__outputNode", + "ramp_speed_up", + [ + null, + null + ], + null, + "[per unit / minute] Maximum ramp up speed. Constant.", + "tech_advanced" + ] + ], + "parameter_types": [ + [ + "commodity", + "co2_content", + "float", + 0 + ], + [ + "commodity", + "price", + "float", + 0 + ], + [ + "commodity", + "price", + "map", + 1 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 2 + ], + [ + "commodity", + "price_ladder_annual", + "map", + 3 + ], + [ + "commodity", + "price_ladder_cumulative", + "map", + 2 + ], + [ + "commodity", + "price_method", + "str", + 0 + ], + [ + "commodity", + "unitsize", + "float", + 0 + ], + [ + "connection", + "availability", + "float", + 0 + ], + [ + "connection", + "availability", + "map", + 1 + ], + [ + "connection", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "connection", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "connection", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "connection", + "delay", + "float", + 0 + ], + [ + "connection", + "delay", + "map", + 1 + ], + [ + "connection", + "discount_rate", + "float", + 0 + ], + [ + "connection", + "discount_rate", + "map", + 1 + ], + [ + "connection", + "efficiency", + "float", + 0 + ], + [ + "connection", + "efficiency", + "map", + 1 + ], + [ + "connection", + "existing", + "float", + 0 + ], + [ + "connection", + "existing", + "map", + 1 + ], + [ + "connection", + "fixed_cost", + "float", + 0 + ], + [ + "connection", + "fixed_cost", + "map", + 1 + ], + [ + "connection", + "invest_cost", + "float", + 0 + ], + [ + "connection", + "invest_cost", + "map", + 1 + ], + [ + "connection", + "invest_max_period", + "map", + 1 + ], + [ + "connection", + "invest_max_total", + "float", + 0 + ], + [ + "connection", + "invest_method", + "str", + 0 + ], + [ + "connection", + "invest_min_period", + "map", + 1 + ], + [ + "connection", + "invest_min_total", + "float", + 0 + ], + [ + "connection", + "is_DC", + "str", + 0 + ], + [ + "connection", + "lifetime", + "float", + 0 + ], + [ + "connection", + "lifetime", + "map", + 1 + ], + [ + "connection", + "lifetime_method", + "str", + 0 + ], + [ + "connection", + "other_operational_cost", + "float", + 0 + ], + [ + "connection", + "other_operational_cost", + "map", + 1 + ], + [ + "connection", + "reactance", + "float", + 0 + ], + [ + "connection", + "retire_max_period", + "map", + 1 + ], + [ + "connection", + "retire_max_total", + "float", + 0 + ], + [ + "connection", + "retire_min_period", + "map", + 1 + ], + [ + "connection", + "retire_min_total", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "float", + 0 + ], + [ + "connection", + "salvage_value", + "map", + 1 + ], + [ + "connection", + "startup_cost", + "float", + 0 + ], + [ + "connection", + "startup_method", + "str", + 0 + ], + [ + "connection", + "transfer_method", + "str", + 0 + ], + [ + "connection", + "virtual_unitsize", + "float", + 0 + ], + [ + "connection__node", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "connection__profile", + "profile_method", + "str", + 0 + ], + [ + "constraint", + "constant", + "float", + 0 + ], + [ + "constraint", + "is_enabled", + "str", + 0 + ], + [ + "constraint", + "sense", + "str", + 0 + ], + [ + "flowGroup", + "flow_aggregator", + "str", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "max_instant_flow", + "map", + 2 + ], + [ + "flowGroup", + "min_cumulative_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_cumulative_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "float", + 0 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 1 + ], + [ + "flowGroup", + "min_instant_flow", + "map", + 2 + ], + [ + "group", + "base_MVA", + "float", + 0 + ], + [ + "group", + "candidate_precapacity_to_avoid_big_m", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "float", + 0 + ], + [ + "group", + "capacity_margin", + "map", + 1 + ], + [ + "group", + "co2_max_period", + "map", + 1 + ], + [ + "group", + "co2_max_total", + "float", + 0 + ], + [ + "group", + "co2_method", + "str", + 0 + ], + [ + "group", + "co2_price", + "float", + 0 + ], + [ + "group", + "co2_price", + "map", + 1 + ], + [ + "group", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "group", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "group", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "group", + "decomposition_method", + "str", + 0 + ], + [ + "group", + "has_inertia", + "str", + 0 + ], + [ + "group", + "has_non_synchronous", + "str", + 0 + ], + [ + "group", + "include_stochastics", + "str", + 0 + ], + [ + "group", + "inertia_limit", + "float", + 0 + ], + [ + "group", + "inertia_limit", + "map", + 1 + ], + [ + "group", + "invest_max_period", + "map", + 1 + ], + [ + "group", + "invest_max_total", + "float", + 0 + ], + [ + "group", + "invest_method", + "str", + 0 + ], + [ + "group", + "invest_min_period", + "map", + 1 + ], + [ + "group", + "invest_min_total", + "float", + 0 + ], + [ + "group", + "new_stepduration", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "float", + 0 + ], + [ + "group", + "non_synchronous_limit", + "map", + 1 + ], + [ + "group", + "penalty_capacity_margin", + "float", + 0 + ], + [ + "group", + "penalty_capacity_margin", + "map", + 1 + ], + [ + "group", + "penalty_inertia", + "float", + 0 + ], + [ + "group", + "penalty_inertia", + "map", + 1 + ], + [ + "group", + "penalty_non_synchronous", + "float", + 0 + ], + [ + "group", + "penalty_non_synchronous", + "map", + 1 + ], + [ + "group", + "print_dispatch", + "str", + 0 + ], + [ + "group", + "print_indicators", + "str", + 0 + ], + [ + "group", + "reference_node", + "str", + 0 + ], + [ + "group", + "share_loss_of_load", + "str", + 0 + ], + [ + "group", + "transfer_method", + "str", + 0 + ], + [ + "model", + "inflation_offset_investment", + "float", + 0 + ], + [ + "model", + "inflation_offset_operations", + "float", + 0 + ], + [ + "model", + "inflation_rate", + "float", + 0 + ], + [ + "model", + "max_flow_for_unconstrained_variables", + "float", + 0 + ], + [ + "model", + "output_horizon", + "str", + 0 + ], + [ + "model", + "periods_available", + "array", + 1 + ], + [ + "model", + "small_number_threshold", + "float", + 0 + ], + [ + "model", + "solves", + "array", + 1 + ], + [ + "model", + "version", + "float", + 0 + ], + [ + "node", + "annual_flow", + "float", + 0 + ], + [ + "node", + "annual_flow", + "map", + 1 + ], + [ + "node", + "availability", + "float", + 0 + ], + [ + "node", + "availability", + "map", + 1 + ], + [ + "node", + "availability", + "map", + 3 + ], + [ + "node", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "node", + "constraint_state_coeff", + "map", + 1 + ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "node", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "node", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "node", + "discount_rate", + "float", + 0 + ], + [ + "node", + "discount_rate", + "map", + 1 + ], + [ + "node", + "existing", + "float", + 0 + ], + [ + "node", + "existing", + "map", + 1 + ], + [ + "node", + "fixed_cost", + "float", + 0 + ], + [ + "node", + "fixed_cost", + "map", + 1 + ], + [ + "node", + "inflow", + "float", + 0 + ], + [ + "node", + "inflow", + "map", + 1 + ], + [ + "node", + "inflow", + "map", + 3 + ], + [ + "node", + "inflow_method", + "str", + 0 + ], + [ + "node", + "invest_cost", + "float", + 0 + ], + [ + "node", + "invest_cost", + "map", + 1 + ], + [ + "node", + "invest_forced", + "float", + 0 + ], + [ + "node", + "invest_forced", + "map", + 1 + ], + [ + "node", + "invest_max_period", + "map", + 1 + ], + [ + "node", + "invest_max_total", + "float", + 0 + ], + [ + "node", + "invest_method", + "str", + 0 + ], + [ + "node", + "invest_min_period", + "map", + 1 + ], + [ + "node", + "invest_min_total", + "float", + 0 + ], + [ + "node", + "lifetime", + "float", + 0 + ], + [ + "node", + "lifetime", + "map", + 1 + ], + [ + "node", + "lifetime_method", + "str", + 0 + ], + [ + "node", + "node_type", + "str", + 0 + ], + [ + "node", + "peak_inflow", + "float", + 0 + ], + [ + "node", + "peak_inflow", + "map", + 1 + ], + [ + "node", + "penalty_down", + "float", + 0 + ], + [ + "node", + "penalty_down", + "map", + 1 + ], + [ + "node", + "penalty_up", + "float", + 0 + ], + [ + "node", + "penalty_up", + "map", + 1 + ], + [ + "node", + "retire_max_period", + "map", + 1 + ], + [ + "node", + "retire_max_total", + "float", + 0 + ], + [ + "node", + "retire_min_period", + "map", + 1 + ], + [ + "node", + "retire_min_total", + "float", + 0 + ], + [ + "node", + "salvage_value", + "float", + 0 + ], + [ + "node", + "salvage_value", + "map", + 1 + ], + [ + "node", + "self_discharge_loss", + "float", + 0 + ], + [ + "node", + "self_discharge_loss", + "map", + 1 + ], + [ + "node", + "storage_binding_method", + "str", + 0 + ], + [ + "node", + "storage_nested_fix_method", + "str", + 0 + ], + [ + "node", + "storage_solve_horizon_method", + "str", + 0 + ], + [ + "node", + "storage_start_end_method", + "str", + 0 + ], + [ + "node", + "storage_state_end", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "float", + 0 + ], + [ + "node", + "storage_state_reference_price", + "map", + 1 + ], + [ + "node", + "storage_state_reference_value", + "float", + 0 + ], + [ + "node", + "storage_state_reference_value", + "map", + 1 + ], + [ + "node", + "storage_state_start", + "float", + 0 + ], + [ + "node", + "virtual_unitsize", + "float", + 0 + ], + [ + "node__profile", + "profile_method", + "str", + 0 + ], + [ + "profile", + "profile", + "map", + 1 + ], + [ + "profile", + "profile", + "map", + 3 + ], + [ + "reserve__upDown__connection__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__connection__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__connection__node", + "reliability", + "float", + 0 + ], + [ + "reserve__upDown__group", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__group", + "penalty_reserve", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "float", + 0 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 1 + ], + [ + "reserve__upDown__group", + "reservation", + "map", + 3 + ], + [ + "reserve__upDown__group", + "reserve_method", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "increase_reserve_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "is_enabled", + "str", + 0 + ], + [ + "reserve__upDown__unit__node", + "large_failure_ratio", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "max_share", + "float", + 0 + ], + [ + "reserve__upDown__unit__node", + "reliability", + "float", + 0 + ], + [ + "solve", + "benders_in_out_weight", + "float", + 0 + ], + [ + "solve", + "benders_max_iter", + "float", + 0 + ], + [ + "solve", + "benders_tolerance", + "float", + 0 + ], + [ + "solve", + "contains_solves", + "array", + 1 + ], + [ + "solve", + "contains_solves", + "str", + 0 + ], + [ + "solve", + "decomposition", + "str", + 0 + ], + [ + "solve", + "fix_storage_periods", + "array", + 1 + ], + [ + "solve", + "fix_storage_periods", + "map", + 2 + ], + [ + "solve", + "invest_periods", + "array", + 1 + ], + [ + "solve", + "invest_periods", + "map", + 2 + ], + [ + "solve", + "new_stepduration", + "float", + 0 + ], + [ + "solve", + "period_timeset", + "map", + 1 + ], + [ + "solve", + "realized_invest_periods", + "array", + 1 + ], + [ + "solve", + "realized_invest_periods", + "map", + 2 + ], + [ + "solve", + "realized_periods", + "array", + 1 + ], + [ + "solve", + "realized_periods", + "map", + 2 + ], + [ + "solve", + "rolling_duration", + "float", + 0 + ], + [ + "solve", + "rolling_solve_horizon", + "float", + 0 + ], + [ + "solve", + "rolling_solve_jump", + "float", + 0 + ], + [ + "solve", + "solve_mode", + "str", + 0 + ], + [ + "solve", + "solver", + "str", + 0 + ], + [ + "solve", + "solver_arguments", + "map", + 1 + ], + [ + "solve", + "solver_mip_gap", + "float", + 0 + ], + [ + "solve", + "solver_precommand", + "str", + 0 + ], + [ + "solve", + "stochastic_branches", + "map", + 4 + ], + [ + "solve", + "timeline_hole_multiplier", + "float", + 0 + ], + [ + "solve", + "years_represented", + "map", + 1 + ], + [ + "timeline", + "timestep_duration", + "map", + 1 + ], + [ + "timeset", + "representative_period_weights", + "map", + 2 + ], + [ + "timeset", + "timeline", + "str", + 0 + ], + [ + "timeset", + "timeset_duration", + "map", + 1 + ], + [ + "unit", + "availability", + "float", + 0 + ], + [ + "unit", + "availability", + "map", + 1 + ], + [ + "unit", + "availability", + "map", + 3 + ], + [ + "unit", + "constraint_invested_capacity_coeff", + "map", + 1 + ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], + [ + "unit", + "conversion_method", + "str", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_max_capacity", + "map", + 1 + ], + [ + "unit", + "cumulative_min_capacity", + "float", + 0 + ], + [ + "unit", + "cumulative_min_capacity", + "map", + 1 + ], + [ + "unit", + "delay", + "float", + 0 + ], + [ + "unit", + "delay", + "map", + 1 + ], + [ + "unit", + "discount_rate", + "float", + 0 + ], + [ + "unit", + "discount_rate", + "map", + 1 + ], + [ + "unit", + "efficiency", + "float", + 0 + ], + [ + "unit", + "efficiency", + "map", + 1 + ], + [ + "unit", + "efficiency", + "map", + 3 + ], + [ + "unit", + "efficiency_at_min_load", + "float", + 0 + ], + [ + "unit", + "existing", + "float", + 0 + ], + [ + "unit", + "existing", + "map", + 1 + ], + [ + "unit", + "fixed_cost", + "float", + 0 + ], + [ + "unit", + "fixed_cost", + "map", + 1 + ], + [ + "unit", + "invest_cost", + "float", + 0 + ], + [ + "unit", + "invest_cost", + "map", + 1 + ], + [ + "unit", + "invest_max_period", + "map", + 1 + ], + [ + "unit", + "invest_max_total", + "float", + 0 + ], + [ + "unit", + "invest_method", + "str", + 0 + ], + [ + "unit", + "invest_min_period", + "map", + 1 + ], + [ + "unit", + "invest_min_total", + "float", + 0 + ], + [ + "unit", + "lifetime", + "float", + 0 + ], + [ + "unit", + "lifetime", + "map", + 1 + ], + [ + "unit", + "lifetime_method", + "str", + 0 + ], + [ + "unit", + "min_downtime", + "float", + 0 + ], + [ + "unit", + "min_load", + "float", + 0 + ], + [ + "unit", + "min_load", + "map", + 1 + ], + [ + "unit", + "min_load", + "map", + 3 + ], + [ + "unit", + "min_uptime", + "float", + 0 + ], + [ + "unit", + "minimum_time_method", + "str", + 0 + ], + [ + "unit", + "retire_max_period", + "map", + 1 + ], + [ + "unit", + "retire_max_total", + "float", + 0 + ], + [ + "unit", + "retire_min_period", + "map", + 1 + ], + [ + "unit", + "retire_min_total", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "float", + 0 + ], + [ + "unit", + "salvage_value", + "map", + 1 + ], + [ + "unit", + "startup_cost", + "float", + 0 + ], + [ + "unit", + "startup_method", + "str", + 0 + ], + [ + "unit", + "virtual_unitsize", + "float", + 0 + ], + [ + "unit__inputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__inputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__inputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__inputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__inputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__inputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__inputNode", + "ramp_speed_up", + "float", + 0 + ], + [ + "unit__node__profile", + "profile_method", + "str", + 0 + ], + [ + "unit__outputNode", + "constraint_flow_coeff", + "map", + 1 + ], + [ + "unit__outputNode", + "conversion_flow_coeff", + "float", + 0 + ], + [ + "unit__outputNode", + "inertia_constant", + "float", + 0 + ], + [ + "unit__outputNode", + "is_non_synchronous", + "str", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 1 + ], + [ + "unit__outputNode", + "other_operational_cost", + "map", + 3 + ], + [ + "unit__outputNode", + "ramp_cost", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_method", + "str", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_down", + "float", + 0 + ], + [ + "unit__outputNode", + "ramp_speed_up", + "float", + 0 + ] + ], + "alternatives": [ + [ + "init", + "Model, solve and time structure" + ], + [ + "base", + "System data (base invest + wind + elec demand)" + ], + [ + "stoch", + "include_stochastics + branch-varying wind + mid-horizon reveal (none invest path)" + ] + ], + "scenarios": [ + [ + "midhorizon", + false, + "Mid-horizon reveal at p2040, genuine n_real=0 {high,low} fan, stochastic_invest_method=none (R-NAME witness)" + ] + ], + "scenario_alternatives": [ + [ + "midhorizon", + "init", + "base" + ], + [ + "midhorizon", + "base", + "stoch" + ], + [ + "midhorizon", + "stoch", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_mid", + null + ], + [ + "timeline", + "y6h", + null + ], + [ + "timeset", + "ts1", + null + ], + [ + "timeset", + "ts2", + null + ], + [ + "unit", + "base", + null + ], + [ + "unit", + "wind", + null + ], + [ + "group__unit", + [ + "stoch_g", + "wind" + ], + null + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + null + ], + [ + "unit__outputNode", + [ + "wind", + "elec" + ], + null + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + null + ] + ], + "entity_alternatives": [ + [ + "node", + [ + "elec" + ], + "init", + true + ], + [ + "unit", + [ + "base" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ] + ], + "parameter_values": [ + [ + "model", + "flextool", + "solves", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJzdG9jaF8ycF9taWQiXX0=", + "array" + ], + "init" + ], + [ + "timeline", + "y6h", + "timestep_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAxLjBdLCBbInQwMDAyIiwgMS4wXSwgWyJ0MDAwMyIsIDEuMF0sIFsidDAwMDQiLCAxLjBdLCBbInQwMDA1IiwgMS4wXSwgWyJ0MDAwNiIsIDEuMF1dfQ==", + "map" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts1", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeline", + [ + "Ink2aCI=", + "str" + ], + "init" + ], + [ + "timeset", + "ts2", + "timeset_duration", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAzLjBdXX0=", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "period_timeset", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAidHMxIl0sIFsicDIwNDAiLCAidHMyIl1dfQ==", + "map" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "realized_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "realized_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSIsICJwMjA0MCJdfQ==", + "array" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "solve_mode", + [ + "InNpbmdsZV9zb2x2ZSI=", + "str" + ], + "init" + ], + [ + "solve", + "stoch_2p_mid", + "years_represented", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMS4wXV19", + "map" + ], + "init" + ], + [ + "node", + "elec", + "node_type", + [ + "ImJhbGFuY2Ui", + "str" + ], + "base" + ], + [ + "node", + "elec", + "penalty_up", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "penalty_down", + [ + "MTAwMDAuMA==", + "float" + ], + "base" + ], + [ + "node", + "elec", + "inflow", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAtMTIwLjBdLCBbInQwMDAyIiwgLTEyMC4wXSwgWyJ0MDAwMyIsIC0xMjAuMF0sIFsidDAwMDQiLCAtMTIwLjBdLCBbInQwMDA1IiwgLTEyMC4wXSwgWyJ0MDAwNiIsIC0xMjAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "wind", + "existing", + [ + "MTAwLjA=", + "float" + ], + "base" + ], + [ + "unit", + "wind", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit__node__profile", + [ + "wind", + "elec", + "wprof" + ], + "profile_method", + [ + "InVwcGVyX2xpbWl0Ig==", + "str" + ], + "base" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjZdLCBbInQwMDAyIiwgMC42XSwgWyJ0MDAwMyIsIDAuNl0sIFsidDAwMDQiLCAwLjBdLCBbInQwMDA1IiwgMC4wXSwgWyJ0MDAwNiIsIDAuMF1dfQ==", + "map" + ], + "base" + ], + [ + "unit", + "base", + "efficiency", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "existing", + [ + "MC4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "virtual_unitsize", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_method", + [ + "ImludmVzdF90b3RhbCI=", + "str" + ], + "base" + ], + [ + "unit", + "base", + "invest_cost", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCAxLjBdLCBbInAyMDQwIiwgMy4wXV19", + "map" + ], + "base" + ], + [ + "unit", + "base", + "discount_rate", + [ + "MC4wNQ==", + "float" + ], + "base" + ], + [ + "unit", + "base", + "lifetime", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "unit", + "base", + "invest_max_total", + [ + "MTUwLjA=", + "float" + ], + "base" + ], + [ + "unit__outputNode", + [ + "base", + "elec" + ], + "other_operational_cost", + [ + "MS4w", + "float" + ], + "base" + ], + [ + "group", + "stoch_g", + "include_stochastics", + [ + "InllcyI=", + "str" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjZdLCBbInQwMDAyIiwgMC42XSwgWyJ0MDAwMyIsIDAuNl1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDEuMF0sIFsidDAwMDUiLCAxLjBdLCBbInQwMDA2IiwgMS4wXV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC42XSwgWyJ0MDAwMiIsIDAuNl0sIFsidDAwMDMiLCAwLjZdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjhdLCBbInQwMDA1IiwgMC44XSwgWyJ0MDAwNiIsIDAuOF1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_mid", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_mid", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwNDAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ] + ] +} From 02d53eb1255a5247d720e7d8ab0d9187a523bb56 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Thu, 8 Oct 2026 15:32:01 +0300 Subject: [PATCH 78/87] feat(stochastic): forecasts-only branch weighting + genuine fans (R2+W7+R4+R3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the forecasts-only re-grounding of branch weighting and the stochastic-value fixtures. The stochastic fan is forecasts only: the realized/trunk self-row never enters the forecast weight denominator. R2/W7 (weight rule): in the three branch-weight producers (_derived_branch.pd_branch_weight_lf, pdt_branch_weight_lf, and the eager _emit_period_calc.emit_branch_weights) compute the structural phantom set (a trunk self-row (d,d) that also has forecast fan members), emit weight 0 for phantom anchors and exclude them from the denominator, so forecasts renormalize to 1; a lone pre-reveal trunk with no fan member stays 1.0. This applies to invest (pd) too (W7) and is unconditional — the W4d-A separate-dispatch weight surgery, dispatch_realization_method_read, and the solve_data/dispatch_realization_method.csv marker channel are removed (spawn reads SolveConfig.dispatch_realization_method_for, not the CSV; schema v75 + migration + resolver + spawn are kept). Also guard the in-process re-solve path: trust ctx.period_branch only when its anchor columns are null-free, else fall back to the raw period__branch (mirrors _collect_lineage_inputs) so the phantom detection isn't defeated under enum activation. R4: the three recourse-NPV preconditions pass unchanged (phantom anchor -> 0, forecasts sum to 1); docstrings updated and test_p3b re-pointed to a genuinely malformed cohort (obligation (ii) is otherwise a renormalization tautology). R3 (fixtures): build_stoch_two_period{,_invest,_hedge} become genuine {high, low} forecast fans (rlz retagged to a forecast). Hand-calcs re-derived and driven-confirmed (hedge RP 173250 / EVPI 15750 / VSS 19687.5; recourse-gate 196875; continuation 70080000). For fan-at-first recourse there is no here-and-now committed stage, so committed invest is 0 (faithful). Retired test_stoch_realized_gating_and_flows (Model 2 realized-dispatch is covered e2e by W8/W4b/W4c). Re-ground the Model 2 fixtures to declare realized as a weight-0 designation (behavior- preserving: all W8/W4b/W4c objectives byte-identical). Post-process (A): calc_costs/calc_slacks now apply pdt_branch_weight to per-(d,t) operational and slack-penalty cost terms (new _branch_weight helper + a pdt_branch_weight reader), so the reported "Total cost (calculated)" is the probability-weighted expected cost matching the LP objective; invest (already weighted), physical diagnostics, and the capacity-margin penalty are left unweighted to match the LP exactly; deterministic/single-branch output is byte-identical (weight 1). 2day scenario: retag its stray realized branch to a forecast (genuine 4-forecast fan; the realized designation was vestigial in a single solve with no realization region) and regenerate the golden + expected_objective (268.081475165 -> 66.9291187913 = the LP objective, zero slack). Add test_single_invest_stochastic_ops pinning the default structure (single per-period invest + branch-fanned operations). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_branch.py | 126 ++++--- flextool/engine_polars/_emit_period_calc.py | 40 +-- flextool/engine_polars/_emit_solve_writers.py | 26 -- flextool/engine_polars/_native_run_model.py | 9 - flextool/process_outputs/_branch_weight.py | 48 +++ flextool/process_outputs/calc_costs.py | 19 + flextool/process_outputs/calc_slacks.py | 16 + flextool/process_outputs/drop_levels.py | 5 + flextool/process_outputs/read_parameters.py | 9 + ...st_non_anticipativity_storage_use_emits.py | 34 +- .../test_branch_weight_applied.py | 24 +- .../test_configurable_nonanticipativity.py | 43 ++- .../test_derived_branch_lineage.py | 26 +- ...lex_2day_stochastic_dispatch_no_storage.py | 16 +- tests/engine_polars/test_invest_na_window.py | 69 ++-- .../engine_polars/test_period_walk_lineage.py | 41 ++- .../test_recourse_invest_gate.py | 149 +++++--- .../test_recourse_invest_hedge.py | 99 +++--- .../test_single_invest_stochastic_ops.py | 184 ++++++++++ .../test_stochastic_continuation_fanout.py | 116 ++++--- .../2_day_stochastic_dispatch/costs__dt.csv | 326 ++++++++++-------- .../summary_solve.csv | 24 +- .../fixtures/build_stoch_realized_dispatch.py | 26 +- tests/fixtures/build_stoch_two_period.py | 10 +- .../fixtures/build_stoch_two_period_hedge.py | 20 +- .../fixtures/build_stoch_two_period_invest.py | 22 +- .../fixtures/build_w4c_divergent_dispatch.py | 6 +- tests/fixtures/stoch_realized_dispatch.json | 29 +- tests/fixtures/stoch_two_period.json | 237 ++++++++++++- tests/fixtures/stoch_two_period_hedge.json | 33 +- tests/fixtures/stoch_two_period_invest.json | 262 ++++++++++++-- tests/fixtures/stochastics.json | 2 +- tests/fixtures/w4b_auto_dispatch.json | 2 +- tests/fixtures/w4b_reserve_dispatch.json | 2 +- tests/fixtures/w4c_divergent_dispatch.json | 2 +- tests/scenarios.yaml | 12 +- tests/test_branch_weights.py | 101 ++++-- tests/test_cost_aggregation_semantics.py | 106 +++--- tests/test_recourse_cost_reconciliation.py | 51 +-- 39 files changed, 1720 insertions(+), 652 deletions(-) create mode 100644 flextool/process_outputs/_branch_weight.py create mode 100644 tests/engine_polars/test_single_invest_stochastic_ops.py diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index ed7a4011f..f86b98c3e 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -157,7 +157,19 @@ def period_branch_pairs_lf( "b": schema_dtype(_enums, "b")} if ctx is not None: pb_df = getattr(ctx, "period_branch", None) - if pb_df is not None and pb_df.height > 0: + # Trust ``ctx.period_branch`` ONLY when its ``d_anchor`` column is + # null-free — under enum activation the ctx frame's anchor column is + # null-poisoned (the ``d_anchor`` axis vocabulary is empty by + # contract), which would drop every trunk self-row ``(d, d)`` and + # silently defeat the forecasts-only ``phantom_periods`` detection + # (phantom weights collapse to a naive 1/Σw instead of 0). This + # mirrors the guard in :func:`_collect_lineage_inputs`; on a + # null-poisoned ctx we fall through to the raw ``period__branch.csv`` + # which carries the correct anchors. + if (pb_df is not None and pb_df.height > 0 + and "d_anchor" in pb_df.columns + and pb_df["d_anchor"].null_count() == 0 + and pb_df["b"].null_count() == 0): return (pb_df.lazy() .select(alias_to_axis("d_anchor", "d"), cast_dim(pl.col("b"), _enums, "b")) @@ -357,32 +369,24 @@ def steps_in_use_lf(workdir: Path | None, .unique()) -def dispatch_realization_method_read( - workdir: Path | None, - *, provider: "object | None" = None, -) -> str: - """Read the per-solve ``dispatch_realization_method`` marker (W4d-A). - - Returns the normalised lower-case mode (``"within_solve"`` / - ``"separate_dispatch"``). An absent / empty / unrecognised marker -> - ``"within_solve"`` (byte-identical prior behaviour), mirroring - :meth:`SolveConfig.dispatch_realization_method_for`. This is a scalar - config read over the standard ``solve_data`` provider channel (same - pattern as ``steps_in_use`` / ``period__branch`` / ``solve_branch_weight``); - it is NOT a quantity Param, so no autoscale registry entry is required. +def phantom_periods(pb_set: "set[tuple[str, str]]") -> set[str]: + """Phantom trunk periods under the forecasts-only cohort rule (R2/W7). + + A *phantom* is a period ``d`` that is both a trunk self-row + (``(d, d) ∈ period__branch``) AND the parent of ≥1 forecast member + (``∃ b ≠ d : (d, b) ∈ period__branch``). The stochastic fan is + forecasts-only (Σ = 1), so a phantom trunk carries no probability mass — + the branch-weight producers emit weight 0 for it and skip it from every + forecast denominator. + + A trunk with NO fan member (``has_fan`` is false) is a genuine pre-reveal + cohort-of-one or a deterministic period and is NOT phantom, so it keeps its + full 1.0 weight. Returns the empty set for a deterministic solve + (``pb_set`` empty) → the rule is a no-op (byte-parity with HEAD). """ - if workdir is None: - return "within_solve" - p = Path(workdir) / "solve_data" / "dispatch_realization_method.csv" - df = _provider_get(provider, p) - if df is None or df.height == 0 or not df.columns: - return "within_solve" - col = "method" if "method" in df.columns else df.columns[0] - raw = df[col][0] - if raw is None: - return "within_solve" - value = str(raw).strip().lower() - return value if value in ("within_solve", "separate_dispatch") else "within_solve" + trunk = {d for d, b in pb_set if d == b} + has_fan = {d for d, b in pb_set if b != d} + return trunk & has_fan # --------------------------------------------------------------------------- @@ -441,6 +445,16 @@ def pd_branch_weight_lf( # Build the parent set: pb_set = frozenset((d2, b)). pb_set = {(row["d"], row["b"]) for row in pb.iter_rows(named=True)} + # Forecasts-only cohort rule (R2/W7, unconditional) — a PHANTOM period is a + # trunk self-row ``(d, d) ∈ pb`` that ALSO fans out to ≥1 forecast member + # ``(d, b), b ≠ d``. The stochastic fan is forecasts-only (Σ=1), so the + # phantom trunk carries NO probability mass: it is skipped from every + # denominator and emits weight 0; the forecast members renormalise among + # themselves. A trunk with NO fan member is a genuine pre-reveal cohort-of- + # one (or a deterministic period) and is NOT phantom → it stays 1.0 (the + # ``has_fan`` guard). This is the W4d-A exclusion made unconditional (no + # ``dispatch_realization_method`` marker) and extended to the invest weight. + phantom = phantom_periods(pb_set) # times_with_first[ts] = {b : (b, ts) ∈ first_ts} first_ts_map = {row["d"]: row["ts"] for row in ft.iter_rows(named=True)} times_with_first: dict[str, set[str]] = {} @@ -456,6 +470,9 @@ def w(b: str) -> float: ts = first_ts_map.get(d) if ts is None: continue + if d in phantom: + rows.append((d, 0.0)) + continue branches_at_ts = times_with_first.get(ts, set()) denom = 0.0 # Iterate (d2, b) pairs (mirrors mod's ``sum {d2, b in pb}``) @@ -465,6 +482,8 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue + if b in phantom: + continue denom += w(b) if denom == 0.0: continue @@ -530,18 +549,13 @@ def pdt_branch_weight_lf( w_lookup = {row["b"]: float(row["w"]) for row in bw.iter_rows(named=True) if row["w"] is not None} pb_set = {(row["d"], row["b"]) for row in pb.iter_rows(named=True)} - # W4d-A — under ``separate_dispatch`` the OPERATIONAL branch weights are - # de-scenario'd: forecasts normalise among THEMSELVES (the realized - # branch is excluded from the denominator) and the realized branch's own - # operational weight → 0 (its operations come from the W4c realized- - # dispatch sibling, so zeroing it in the hedge is safe). The marker is - # the PARENT solve's own flag; an absent marker → within_solve → the - # verbatim legacy arm below (byte-parity). ``realized_set`` is the - # self-anchored realized periods, the SAME ``(d, d) ∈ pb`` predicate - # ``derive_solve_branch_weight`` uses. - separate = dispatch_realization_method_read( - workdir, provider=provider) == "separate_dispatch" - realized_set = {d for d, b in pb_set if d == b} + # Forecasts-only cohort rule (R2, unconditional) — see + # :func:`pd_branch_weight_lf`. A PHANTOM period (``(d, d) ∈ pb`` that also + # fans out to a forecast member) carries no probability mass: emit 0 and + # skip it from every forecast denominator. Applied unconditionally (no + # ``dispatch_realization_method`` marker); a lone pre-reveal / deterministic + # trunk has no fan member and is NOT phantom → stays 1.0. + phantom = phantom_periods(pb_set) # branches_for_t[t] = {b : (b, t) ∈ dt} — derived from the dt set. # Mirrors flextool's ``branches_for_t`` from steps_in_use rows. branches_for_t: dict[str, set[str]] = {} @@ -557,9 +571,8 @@ def w(b: str) -> float: rows: list[tuple[str, str, float]] = [] pb_iter = list(pb.iter_rows(named=True)) for d, t in dt_pairs: - if separate and d in realized_set: - # Realized operational weight → 0 (bypass normalisation; the - # realized-dispatch sibling owns these operations). + if d in phantom: + # Phantom trunk operational weight → 0 (forecasts carry the mass). rows.append((d, t, 0.0)) continue branches_with_t = branches_for_t.get(t, set()) @@ -570,8 +583,8 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue - if separate and b in realized_set: - # Exclude the realized self-branch from the forecast denom. + if b in phantom: + # Exclude the phantom trunk from the forecast denominator. continue denom += w(b) if denom == 0.0: @@ -1469,6 +1482,28 @@ def check_recourse_npv_preconditions( Deterministic message order. Raising wrapper: :func:`assert_recourse_npv_preconditions`. + FORECASTS-ONLY MODEL (R2/R4). The stochastic fan is forecasts-only + (Σ = 1): a trunk period that is both a self-row and the parent of ≥1 + forecast is a PHANTOM carrying weight 0; the forecast members + renormalise among themselves. The three obligations are read under + that model: + + (i) leaf-weight constancy — the probability-weight is constant + along each post-reveal forecast chain (a weight-0 phantom + anchor is a pre-reveal / cohort-of-one exclusion, not a + violation); + (ii) cohort sum-to-1 — the forecast members of a revealing cohort + sum to 1 (the weight-0 phantom contributes 0). Because the + producer (``pd_branch_weight_lf``) renormalises each + first-step forecast group to 1 and zeroes the phantom, a + WELL-FORMED cohort satisfies (ii) BY CONSTRUCTION — (ii) is + now largely a renormalisation tautology on the producer path. + It retains diagnostic power ONLY against a genuinely malformed + cohort whose forecast members straddle DIFFERENT first-step + normalization groups (each normalises to 1.0 independently, so + the cohort sums > 1) — see ``test_p3b_cohort_sum_violation``; + (iii) byte-equal fan-member year rows — unchanged. + These obligations pin the arithmetic that makes a SET-shaped (unweighted) lineage filter numerically correct for trunk-crossing windows and realized-anchor windows (recourse plan @@ -1573,6 +1608,11 @@ def check_recourse_npv_preconditions( ) # --- (ii) cohort weights sum to 1 ------------------------------------- + # Forecasts-only (R2/R4): the weight-0 phantom anchor contributes 0 and + # the forecast members renormalise to 1, so a well-formed cohort sums to + # 1 BY CONSTRUCTION (a renormalisation tautology on the producer path). + # This loop still catches a malformed cohort whose forecast members sit + # in different first-step normalization groups (each → 1.0 → sum > 1). # Fan rows (anchor -> members) in period__branch row order. fan_of: dict[str, list[str]] = {} for d, b in pb_rows: diff --git a/flextool/engine_polars/_emit_period_calc.py b/flextool/engine_polars/_emit_period_calc.py index 7650ab27a..26b4d68f2 100644 --- a/flextool/engine_polars/_emit_period_calc.py +++ b/flextool/engine_polars/_emit_period_calc.py @@ -468,28 +468,26 @@ def w(b: str) -> float: period_in_use = _read_singles(solve_data_dir / "period_in_use_set.csv", provider=provider) - # W4d-A — the PARENT solve's resolved dispatch_realization_method marker - # (absent → within_solve → byte-parity). Under ``separate_dispatch`` the - # OPERATIONAL pdt weights are de-scenario'd (forecasts renormalise among - # themselves; the realized branch's own operational weight → 0). Kept in - # lock-step with ``_derived_branch.pdt_branch_weight_lf`` (the authoritative - # LP weight) so this emitted CSV stays byte-parity with the LP Param. The - # pd loop above is UNTOUCHED (invest weight, owned by W7). - method_df = _read_csv(solve_data_dir / "dispatch_realization_method.csv", - ["method"], provider=provider) - separate_dispatch = False - if method_df.height > 0: - raw = method_df["method"][0] - if raw is not None: - v = str(raw).strip().lower() - separate_dispatch = v == "separate_dispatch" - realized_set = {d for d, b in pb_rows if d == b} + # Forecasts-only cohort rule (R2/W7, unconditional) — a PHANTOM period is a + # trunk self-row ``(d, d) ∈ pb`` that ALSO fans out to a forecast member; + # it carries no probability mass → weight 0, excluded from every forecast + # denominator. A lone pre-reveal / deterministic trunk is NOT phantom and + # keeps 1.0. Kept in lock-step with + # ``_derived_branch.pdt_branch_weight_lf`` (the authoritative LP weight) via + # the shared :func:`_derived_branch.phantom_periods` helper, so this emitted + # CSV stays byte-parity with the LP Param. Now applied to the pd (invest) + # loop too (= W7), not just pdt. + from flextool.engine_polars._derived_branch import phantom_periods + phantom = phantom_periods(pb_set) pd_rows: list[tuple[str, float]] = [] for d in period_in_use: ts = first_time_for_d.get(d) if ts is None: continue + if d in phantom: + pd_rows.append((d, 0.0)) + continue branches_at_ts = times_with_first_set.get(ts, set()) denom = 0.0 for d2, b in pb_rows: @@ -497,6 +495,8 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue + if b in phantom: + continue denom += w(b) if denom == 0.0: continue @@ -506,8 +506,8 @@ def w(b: str) -> float: pdt_rows: list[tuple[str, str, float]] = [] for d, t in dt_pairs: - if separate_dispatch and d in realized_set: - # Realized operational weight → 0 (W4d-A; sibling owns these ops). + if d in phantom: + # Phantom trunk operational weight → 0 (forecasts carry the mass). pdt_rows.append((d, t, 0.0)) continue branches_with_t = branches_for_t.get(t, set()) @@ -517,8 +517,8 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue - if separate_dispatch and b in realized_set: - # Exclude the realized self-branch from the forecast denom. + if b in phantom: + # Exclude the phantom trunk from the forecast denominator. continue denom += w(b) if denom == 0.0: diff --git a/flextool/engine_polars/_emit_solve_writers.py b/flextool/engine_polars/_emit_solve_writers.py index 581d57792..7ed7086b7 100644 --- a/flextool/engine_polars/_emit_solve_writers.py +++ b/flextool/engine_polars/_emit_solve_writers.py @@ -593,32 +593,6 @@ def emit_branch__period_relationship( derive_branch__period_relationship(period__branch)) -def emit_dispatch_realization_method( - method: str, - filename: str, - *, provider, -) -> None: - """Emit the per-solve ``dispatch_realization_method`` marker (W4d-A). - - A one-row ``(method,)`` frame recording the PARENT solve's resolved - ``solve.dispatch_realization_method`` (``within_solve`` / - ``separate_dispatch``). Co-located with the other per-solve weight - inputs (``solve_branch_weight.csv`` / ``period__branch.csv``) so the - operational ``pdt_branch_weight`` producers can read it through the - standard ``solve_data`` provider channel. - - The operational branch-weight producers consult this marker to decide - whether the realized operational weight is de-scenario'd - (``separate_dispatch``: forecasts renormalise among themselves and the - realized branch's own operational weight → 0, since its operations come - from the W4c realized-dispatch sibling) or left as the legacy - sibling-normalised weight (``within_solve``). An ABSENT marker is read - as ``within_solve`` → byte-identical prior behaviour. - """ - _emit_path(provider, filename, - _to_utf8_frame(("method",), [(method,)])) - - def derive_solve_branch_weight( complete_solve: str, active_time_list: dict[str, list[tuple[str, ...]]], diff --git a/flextool/engine_polars/_native_run_model.py b/flextool/engine_polars/_native_run_model.py index 9248c53bf..1840391bc 100644 --- a/flextool/engine_polars/_native_run_model.py +++ b/flextool/engine_polars/_native_run_model.py @@ -851,15 +851,6 @@ def _mem_cp(label: str, user_label: str, str(wf / "solve_data/period__branch.csv"), provider=sub_solve_provider, ) - # W4d-A — record the PARENT solve's resolved - # dispatch_realization_method so the operational pdt_branch_weight - # producers can de-scenario the realized operational weight under - # ``separate_dispatch`` (absent marker → within_solve → byte-parity). - solve_writers.emit_dispatch_realization_method( - state.solve.dispatch_realization_method_for(complete_solve[solve]), - str(wf / "solve_data/dispatch_realization_method.csv"), - provider=sub_solve_provider, - ) solve_writers.emit_branch_weights_and_map( complete_solve[solve], active_time_lists[solve], diff --git a/flextool/process_outputs/_branch_weight.py b/flextool/process_outputs/_branch_weight.py new file mode 100644 index 000000000..6f69087f8 --- /dev/null +++ b/flextool/process_outputs/_branch_weight.py @@ -0,0 +1,48 @@ +"""Shared post-process branch-probability weighting (option A). + +The LP objective weights every per-(d, t) operational and penalty cost +term by ``pdt_branch_weight[d, t]`` (``flextool.engine_polars.model`` folds +it into ``op_factor`` / ``startup_factor``), so the LP objective is the +probability-weighted expectation over the forecast fan. The post-process +cost reconstruction must apply the SAME per-(d, t) weight so that both the +per-step detail (``costs__dt.csv``) and the ``Total cost (calculated)`` +summary reconcile to the LP objective. + +Scope (mirrors the LP exactly): + +* Weighted — every per-(d, t) operational cost term (commodity buy/sell, + per-process commodity, CO2 cost, other operational, startup) and every + per-(d, t) penalty term (node-state up/down, inertia, non-synchronous, + reserve up/down). +* NOT weighted — the capacity-margin penalty (a planning-level slack the + LP intentionally leaves un-weighted; see ``_group_slack.py`` §9.3) and + every raw physical diagnostic (``*_not_annualized`` slack-event sums, + physical CO2 tonnes, reserve MW held). + +Deterministic / single-branch solves have ``pdt_branch_weight`` absent +(``None``) or a dense all-``1.0`` series → every weighting is an exact +``× 1.0`` no-op, so all non-stochastic outputs are byte-identical. +""" + + +def weight_dt_by_branch(frame, pdt_weight): + """Multiply a ``(solve, period, time)``-indexed cost frame/Series by + the per-(d, t) branch weight. + + ``frame`` — a per-(d, t) cost ``DataFrame`` (columns = entities / cost + categories) or ``Series``. ``pdt_weight`` — the ``par.pdt_branch_weight`` + ``Series`` indexed by ``(solve, period, time)`` (or ``None`` / empty on + deterministic runs). + + Missing ``(d, t)`` rows default to weight ``1.0`` (the LP's dense + ``pdt_branch_weight`` coalesce default). ``None`` / empty weight or an + empty ``frame`` returns the input unchanged (byte-parity). + """ + if frame is None or pdt_weight is None: + return frame + if getattr(pdt_weight, "empty", False) or getattr(frame, "empty", False): + return frame + factors = pdt_weight.reindex(frame.index).fillna(1.0) + if getattr(frame, "ndim", 2) == 1: + return frame.mul(factors) + return frame.mul(factors, axis=0) diff --git a/flextool/process_outputs/calc_costs.py b/flextool/process_outputs/calc_costs.py index f330c4147..c48f03e15 100644 --- a/flextool/process_outputs/calc_costs.py +++ b/flextool/process_outputs/calc_costs.py @@ -2,6 +2,7 @@ import pandas as pd from flextool.process_outputs._annualize import annualize_dt_to_d +from flextool.process_outputs._branch_weight import weight_dt_by_branch def _agg_period_or_empty(df: pd.DataFrame, realized_periods, period_level: str = 'period') -> pd.DataFrame: @@ -38,7 +39,14 @@ def compute_costs(par, s, v, r) -> None: # Per-step scaling factor: step_duration × timestep_weight. Used by all # per-(d, t) cost terms below to match the objective in flextool.mod. + # Option (A): fold in pdt_branch_weight[d, t] so every operational cost + # term built from step_x_rp (commodity buy/sell, per-process commodity, + # other operational) carries the LP's per-(d, t) probability weight. + # step_x_rp feeds ONLY cost terms here (never a physical quantity), so + # folding the weight in is safe; deterministic runs weight by 1.0. + _pdt_bw = getattr(par, "pdt_branch_weight", None) step_x_rp = par.step_duration.mul(par.timestep_weight, axis=0) + step_x_rp = weight_dt_by_branch(step_x_rp, _pdt_bw) commodity_price = par.commodity_price[commodity_node.get_level_values('commodity').unique()] commodity_price.columns = commodity_price.columns.join(commodity_node) @@ -143,7 +151,11 @@ def _per_process_commodity_cost(flow_frame): # aggregated from the rp-weighted cost_dt directly (instead of # multiplying the annualized physical emissions by price, which would # drop the per-step rp weighting). + # Option (A): weight the CO2 *cost* by pdt_branch_weight (the LP's CO2 + # price term carries op_factor — model.py §4). The physical CO2 tonnes + # (group_co2_d / process_emissions_co2_d above) stay UNWEIGHTED. r.group_cost_co2_dt = r.group_co2_dt.mul(par.group_co2_price).mul(par.timestep_weight, axis=0) + r.group_cost_co2_dt = weight_dt_by_branch(r.group_cost_co2_dt, _pdt_bw) r.group_cost_co2_d = r.group_cost_co2_dt.groupby('period').sum() r.cost_co2_dt = r.group_cost_co2_dt.sum(axis=1) r.cost_co2_d = r.group_cost_co2_d.sum(axis=1) @@ -164,6 +176,9 @@ def _per_process_commodity_cost(flow_frame): .mul(par.timestep_weight, axis=0) .T.groupby('process').sum().T ) + # Option (A): same per-(d, t) weight as the per-group CO2 cost above so + # the per-process CO2 cost reconciles to r.cost_co2_dt. + r.cost_process_co2_dt = weight_dt_by_branch(r.cost_process_co2_dt, _pdt_bw) # --- Operational costs --- # Mod objective (~line 2347-2376): flow × unitsize × varCost × @@ -202,6 +217,10 @@ def _per_process_commodity_cost(flow_frame): period_costs = par.process_startup_cost.loc[periods, valid_processes] period_costs.index = cost.index r.cost_startup_dt[valid_processes] = cost.mul(period_costs).mul(par.timestep_weight, axis=0) + # Option (A): weight the startup *cost* by pdt_branch_weight (the LP's + # startup_factor carries it — model.py §startup). The physical start + # count (r.process_startup_dt above) stays UNWEIGHTED. + r.cost_startup_dt = weight_dt_by_branch(r.cost_startup_dt, _pdt_bw) # --- Investment costs --- r.cost_entity_invest_d = v.invest.mul(par.entity_unitsize[v.invest.columns]).mul(par.entity_annual_discounted) diff --git a/flextool/process_outputs/calc_slacks.py b/flextool/process_outputs/calc_slacks.py index 6d459cc7f..c5a631c0e 100644 --- a/flextool/process_outputs/calc_slacks.py +++ b/flextool/process_outputs/calc_slacks.py @@ -1,6 +1,7 @@ import pandas as pd from flextool.process_outputs._annualize import annualize_dt_to_d +from flextool.process_outputs._branch_weight import weight_dt_by_branch def compute_slacks(par, s, v, r) -> None: @@ -28,7 +29,14 @@ def compute_slacks(par, s, v, r) -> None: """ # Per-step scaling factor for all per-(d, t) cost terms: # step_duration × timestep_weight. Combined once for readability. + # Option (A): fold in pdt_branch_weight[d, t] so the inertia / + # non-synchronous penalty costs built from step_x_rp carry the LP's + # per-(d, t) probability weight. step_x_rp feeds ONLY penalty-cost + # terms here (the physical slack / reserve diagnostics use step_duration + # directly), so folding the weight in is safe; deterministic → ×1.0. + _pdt_bw = getattr(par, "pdt_branch_weight", None) step_x_rp = par.step_duration.mul(par.timestep_weight, axis=0) + step_x_rp = weight_dt_by_branch(step_x_rp, _pdt_bw) r.reserves_dt = v.reserve.mul(par.step_duration, axis=0) # Average MW held over the period: Σ(MW × step_duration) / total period hours. @@ -61,6 +69,10 @@ def compute_slacks(par, s, v, r) -> None: .mul(par.timestep_weight, axis=0) r.costPenalty_node_state_upDown_dt = pd.concat([upward_node_penalty, downward_node_penalty], axis=1, keys=['up', 'down'], names=['upDown']) r.costPenalty_node_state_upDown_dt = r.costPenalty_node_state_upDown_dt.reorder_levels([1, 0], axis=1) + # Option (A): weight the node-state slack *penalty* by pdt_branch_weight + # (the LP's vq_up/down term carries op_factor — model.py §obj). The raw + # physical slack (upward/downward_node_slack_dt above) stays UNWEIGHTED. + r.costPenalty_node_state_upDown_dt = weight_dt_by_branch(r.costPenalty_node_state_upDown_dt, _pdt_bw) r.costPenalty_node_state_upDown_d = r.costPenalty_node_state_upDown_dt.groupby(level='period').sum() # Inertia penalty: mod uses × step_duration × timestep_weight (objective line ~2380). @@ -101,3 +113,7 @@ def compute_slacks(par, s, v, r) -> None: .mul(par.timestep_weight, axis=0) \ .mul(par.reserve_upDown_group_penalty, axis=1) \ .mul(par.reserve_upDown_group_reservation, axis=1) + # Option (A): weight the reserve slack *penalty* by pdt_branch_weight + # (the LP's q_reserve penalty carries op_factor — _reserve.py §obj). The + # physical reserve held (r.q_reserves_dt above) stays UNWEIGHTED. + r.costPenalty_reserve_upDown_dt = weight_dt_by_branch(r.costPenalty_reserve_upDown_dt, _pdt_bw) diff --git a/flextool/process_outputs/drop_levels.py b/flextool/process_outputs/drop_levels.py index 5af6c985b..f052cfa33 100644 --- a/flextool/process_outputs/drop_levels.py +++ b/flextool/process_outputs/drop_levels.py @@ -39,6 +39,11 @@ # post-decision value). _PAR_DROP = [ 'step_duration', 'timestep_weight', 'flow_min', 'flow_max', + # pdt_branch_weight — same (solve, period, time) → (period, time) + # treatment as step_duration/timestep_weight (the per-(d, t) cost + # terms it multiplies are already solve-stripped). keep='last' matches: + # the roll that commits a realized (period, time) carries its weight. + 'pdt_branch_weight', 'process_availability', 'process_source_sink_varCost', 'process_slope', 'process_section', 'node_self_discharge_loss', 'node_penalty_up', 'node_penalty_down', diff --git a/flextool/process_outputs/read_parameters.py b/flextool/process_outputs/read_parameters.py index e820bbc72..425ad21ab 100644 --- a/flextool/process_outputs/read_parameters.py +++ b/flextool/process_outputs/read_parameters.py @@ -1439,6 +1439,15 @@ def read_parameters( p.pd_branch_weight = _pd_series_solve_period( getattr(flex_data, "pd_branch_weight", None), solve_name=solve_name, ) + # Option (A): the per-(d, t) branch weight the LP objective folds into + # ``op_factor`` / ``startup_factor`` (model.py). ``calc_slacks`` / + # ``calc_costs`` apply it to every per-(d, t) operational + penalty cost + # term so ``costs__dt.csv`` and the ``Total cost (calculated)`` summary + # equal the probability-weighted LP objective. Absent (deterministic) + # → an empty Series → the weighting is an exact ×1.0 no-op (byte-parity). + p.pdt_branch_weight = _pdt_series_solve_period_time( + getattr(flex_data, "pdt_branch_weight", None), solve_name=solve_name, + ) # inflation factors — Series((solve, period)). p.inflation_factor_operations_yearly = _pd_series_solve_period( diff --git a/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py b/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py index 18dea59a5..d1ac4b246 100644 --- a/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py +++ b/tests/engine_polars/emission/test_non_anticipativity_storage_use_emits.py @@ -1,19 +1,22 @@ """Tier-7: ``non_anticipativity_storage_use`` is emitted with the right shape. -Fixture: ``work_2day_stochastic_dispatch_full_storage`` — 4 branches -(period1, period1_upper, period1_lower, period1_mid) × 48 timesteps, -1 stochastic-group node (hydro_reservoir), with the .mod's stochastic -flag set via ``groupIncludeStochastics``. +Fixture: ``work_2day_stochastic_dispatch_full_storage`` — a genuine +4-forecast fan (period1_realized, period1_upper, period1_lower, +period1_mid) × 48 timesteps, 1 stochastic-group node (hydro_reservoir), +with the .mod's stochastic flag set via ``groupIncludeStochastics``. +(``realized`` was retagged from a designation to a plain forecast, so all +four named branches are forecast members; the real-named ``period1`` is a +weight-0 phantom trunk.) The constraint pins the per-(n, t) net storage charge equal FORECAST-TO- FORECAST: the raw ``{(d, b) ∈ period__branch : d != b ∧ b ∈ period_in_use}`` cohort is re-grounded (per anchor group) to a forecast star keyed on the deterministic reference forecast ``d_ref`` (the lexicographically smallest forecast — ``period1_lower`` here), tying the OTHER forecasts to it and -NEVER to the realised trunk ``period1``. For this fixture: 1 stoch node × -2 sibling pairs (``period1_lower`` → ``period1_mid`` / ``period1_upper``, -after dropping the trunk ``period1`` and the metadata-only -``period1_realized``) × 48 realised-dispatch timesteps = 96 LP rows. +NEVER to the phantom trunk ``period1``. For this fixture: 1 stoch node × +3 sibling pairs (``period1_lower`` → ``period1_mid`` / ``period1_realized`` +/ ``period1_upper``, with the trunk ``period1`` dropped as a weight-0 +phantom) × 48 realised-dispatch timesteps = 144 LP rows. Also asserts the three other non-anticipativity families are absent (no v_online / v_reserve in this fixture). @@ -48,13 +51,14 @@ def test_non_anticipativity_storage_use_emits_full_shape( assert data.period_in_use_set is not None # Domain shape. stoch_nodes via groupStochastic ∩ group_node: - # add_stochastics group → hydro_reservoir. After the forecast-to- - # forecast re-ground the star is d_ref=period1_lower → {period1_mid, - # period1_upper}: 2 sibling pairs (NOT 3 — the trunk period1 is no - # longer an anchor). dtna: 48 rows re-keyed onto d_ref. Total: - # 1 × 2 × 48 = 96 LP rows. + # add_stochastics group → hydro_reservoir. The 2day fixture is now a + # genuine 4-forecast fan (realized retagged to a forecast), so the + # forecast-to-forecast star is d_ref=period1_lower → {period1_mid, + # period1_realized, period1_upper}: 3 sibling pairs (the trunk period1 + # is a weight-0 phantom, never an anchor). dtna: 48 rows re-keyed onto + # d_ref. Total: 1 × 3 × 48 = 144 LP rows. n_stoch_nodes = 1 # hydro_reservoir - n_db_pairs = 2 # period1_lower → {period1_mid, period1_upper} + n_db_pairs = 3 # period1_lower → {period1_mid, period1_realized, period1_upper} n_dtna = data.dt_non_anticipativity.height assert n_dtna == 48 expected = n_stoch_nodes * n_db_pairs * n_dtna @@ -74,7 +78,7 @@ def test_non_anticipativity_storage_use_emits_full_shape( pl.col("d").cast(pl.Utf8), pl.col("b").cast(pl.Utf8)) assert set(over_u["d"].unique().to_list()) == {"period1_lower"} assert set(over_u["b"].unique().to_list()) == { - "period1_mid", "period1_upper"} + "period1_mid", "period1_realized", "period1_upper"} assert "period1" not in over_u["d"].unique().to_list() assert "period1" not in over_u["b"].unique().to_list() diff --git a/tests/engine_polars/test_branch_weight_applied.py b/tests/engine_polars/test_branch_weight_applied.py index e5dfdae74..1a23c302c 100644 --- a/tests/engine_polars/test_branch_weight_applied.py +++ b/tests/engine_polars/test_branch_weight_applied.py @@ -15,9 +15,13 @@ test injects non-unit weights and asserts: 1. **Normalisation reaches the CSV / FlexData.** Injecting input - weights ``realized=1 / upper=2 / lower=3 / mid=4`` (sum 10) - produces sibling-normalised ``pd_branch_weight`` / - ``pdt_branch_weight`` of ``0.1 / 0.2 / 0.3 / 0.4`` — NOT a dense 1.0. + weights ``realized=1 / upper=2 / lower=3 / mid=4`` produces + sibling-normalised ``pd_branch_weight`` / ``pdt_branch_weight`` of + ``0 / 2⁄9 / 3⁄9 / 4⁄9`` — NOT a dense 1.0. Under the forecasts-only + model the ``realized`` branch is the realized:yes designation (it owns + the real period name, a weight-0 phantom) and is PULLED from the + weighted fan; the three FORECASTS ``upper`` / ``lower`` / ``mid`` + (input 2/3/4) renormalise among themselves to 2⁄9 / 3⁄9 / 4⁄9. The normalisation is done by ``apply_derived_g`` → ``_derived_branch.apply_branch_cluster`` (``pd_branch_weight_lf`` / @@ -65,8 +69,15 @@ # Normalised branch weights expected once the cascade folds the injected -# input weights ``1 / 2 / 3 / 4`` (sum 10): each value is ``w_b / sum_b``. -_BASE_NORMALISED_WEIGHTS = {0.1, 0.2, 0.3, 0.4} +# input weights ``realized=1 / upper=2 / lower=3 / mid=4`` under the +# forecasts-only rule (R2). ``phantom_periods`` is purely STRUCTURAL: the +# weight-0 phantom is the real-named TRUNK period (its ``(d,d)`` self-row +# that also fans) — NOT the branch merely *named* ``realized``. The 2day +# fixture is a genuine 4-forecast fan (realized retagged to a forecast), so +# all four named branches are forecast MEMBERS that renormalise among +# themselves over sum 10 → 0.1 / 0.2 / 0.3 / 0.4; the CSV value set also +# carries the phantom trunk 0.0 member. +_BASE_NORMALISED_WEIGHTS = {0.0, 0.1, 0.2, 0.3, 0.4} # Two sum-preserving (sum == 10) input-weight distributions over the # four branches. The redistribution keeps the denominator constant so @@ -192,7 +203,8 @@ def swapped_run(tmp_path_factory, test_solver_config_dir): def test_branch_weights_normalised_in_csv_and_flexdata(base_run): """NORMALISATION: injected 1/2/3/4 input weights fold to the - sibling-normalised 0.1/0.2/0.3/0.4 in both emitted weight CSVs — + forecasts-only sibling-normalised {0, 2⁄9, 3⁄9, 4⁄9} in both emitted + weight CSVs (realized pulled → phantom 0; forecasts renormalise) — NOT a dense 1.0 (which is what a non-stochastic / clobbered path would emit).""" workdir, _sol = base_run diff --git a/tests/engine_polars/test_configurable_nonanticipativity.py b/tests/engine_polars/test_configurable_nonanticipativity.py index 350149ab5..6e35e4f8f 100644 --- a/tests/engine_polars/test_configurable_nonanticipativity.py +++ b/tests/engine_polars/test_configurable_nonanticipativity.py @@ -25,13 +25,20 @@ Hand-verified reference numbers (deterministic LP; see the builder): -* ``pinned_ops`` (unset): storage NA fires (12 rows), the set CSV holds - the whole 6-step horizon, and ``v_state[resv]`` is IDENTICAL across - mid / up / low (all 0.5) — the branches cannot follow their opposite - surplus/deficit timing. Objective 1_920_800; shared v_invest 12. +* ``pinned_ops`` (unset): storage NA fires, the set CSV holds the whole + 6-step horizon, and ``v_state[resv]`` is IDENTICAL across the two + forecast copies up / low — the branches cannot follow their opposite + surplus/deficit timing. Objective 2_037_600. * ``free_ops`` (``[]``): zero NA rows, header-only set CSV, and ``v_state[resv]`` DIVERGES (up rises above 0.5, low falls below). - Objective 757_369.4 < pinned; shared v_invest 4.5. + Objective 764_954.1 < pinned; shared v_invest 4.5. + +R3 forecasts-only note: the fixture declares a realized designation +``mid`` (realized:yes) alongside the two forecasts ``up`` / ``low``. +Under the corrected model ``mid`` is PULLED from the weighted fan and the +two forecasts renormalise to {up:0.5, low:0.5}; the objectives above are +the driven optima under that weighting (mid at weight 0 contributes only +its real-named-trunk data, not probability mass). """ from __future__ import annotations @@ -43,8 +50,8 @@ SOLVE = "stoch_2p_free" # Deterministic LP reference objectives (see the fixture builder). -PINNED_OBJ = 1_920_800.0 -FREE_OBJ = 757_369.4 +PINNED_OBJ = 2_037_600.0 +FREE_OBJ = 764_954.1 # --------------------------------------------------------------------------- @@ -160,24 +167,26 @@ def test_free_ops_shared_single_v_invest(free_ops_resolved) -> None: @pytest.mark.solver def test_free_ops_v_state_diverges_across_branches(free_ops_resolved) -> None: - """§7 (F6): with operations freed each branch reaches its OWN storage - optimum, so v_state[resv] is strictly distinct across mid / up / low — - deterministic given the branch-asymmetric wind timing. ``up`` (surplus - early) charges above 0.5; ``low`` (deficit early) discharges below; - ``mid`` (flat) stays idle.""" + """§7 (F6), R3 re-grounded: with operations freed each FORECAST reaches + its OWN storage optimum, so v_state[resv] is strictly distinct across + the two forecasts ``up`` / ``low`` — deterministic given the + branch-asymmetric wind timing. ``up`` (surplus early) charges above + 0.5; ``low`` (deficit early) discharges below. The realized + designation ``mid`` is a weight-0 phantom under forecasts-only (its + real-named trunk carries no probability mass), so its storage + trajectory is an uncosted free variable (driven to 0) and is NOT the + idle-at-0.5 member it was when ``mid`` still carried fan weight; the + probability-relevant divergence is between the two forecasts.""" _data, sol, _pb = free_ops_resolved traj = _v_state_by_branch(sol, "p2035") assert set(traj) == {"p2035", "p2035_up", "p2035_low"} up = traj["p2035_up"] low = traj["p2035_low"] - mid = traj["p2035"] - # mid trunk (flat wind) keeps storage idle at the 0.5 reference. - assert mid == [0.5, 0.5, 0.5] # up strictly above, low strictly below — at every step, and the two - # branches are pairwise distinct (a UNIQUE per-branch optimum). + # FORECASTS are distinct (a UNIQUE per-forecast optimum). assert all(u > 0.5 for u in up), up assert all(lo < 0.5 for lo in low), low - assert up != low != mid + assert up != low @pytest.mark.solver diff --git a/tests/engine_polars/test_derived_branch_lineage.py b/tests/engine_polars/test_derived_branch_lineage.py index 73cdc5067..03a3aa130 100644 --- a/tests/engine_polars/test_derived_branch_lineage.py +++ b/tests/engine_polars/test_derived_branch_lineage.py @@ -97,6 +97,27 @@ def _rows(df: pl.DataFrame) -> list[tuple[str, str]]: ("p2040_low", "p2040_low"), ] +# The §4.1 table for the REAL re-grounded ``stoch_two_period`` fixture +# (R3): a genuine {high, low} forecast fan (n_real = 0), so both forecast +# scenario paths are active in period_in_use — the realized/reference +# trunk path (p2035 ↔ p2040) PLUS the high path PLUS the low path, 12 rows +# (vs the hand-crafted 8-row ``DD_41`` whose PB_41 keeps ``_rlz`` as a +# metadata-only, not-in-use member). +DD_41_STOCH = [ + ("p2035", "p2035"), + ("p2035", "p2040"), + ("p2035_high", "p2035_high"), + ("p2035_high", "p2040_high"), + ("p2035_low", "p2035_low"), + ("p2035_low", "p2040_low"), + ("p2040", "p2035"), + ("p2040", "p2040"), + ("p2040_high", "p2035_high"), + ("p2040_high", "p2040_high"), + ("p2040_low", "p2035_low"), + ("p2040_low", "p2040_low"), +] + # §4.3 — deterministic multi-period all-pairs. DD_43 = [ ("p2035", "p2035"), @@ -397,12 +418,13 @@ def stoch_data(stoch_wf): def test_t10_integration_stoch(stoch_data): """T10: end-to-end ``load_flextool`` on the stoch workdir — the - §4.1 8-row table, pd-NA empty (pins provider seeding + + §4.1 table for the genuine {high, low} fan (12 rows: realized/reference + trunk + both forecast paths), pd-NA empty (pins provider seeding + apply_derived_g wiring including enum activation, i.e. the §3.1.1 null-guard actually engages).""" dd = stoch_data.dd_same_scenario assert dd is not None - assert _rows(dd) == DD_41 + assert _rows(dd) == DD_41_STOCH pd_na = stoch_data.pd_non_anticipativity assert pd_na is not None assert pd_na.columns == ["d", "b"] diff --git a/tests/engine_polars/test_flex_2day_stochastic_dispatch_no_storage.py b/tests/engine_polars/test_flex_2day_stochastic_dispatch_no_storage.py index 835179b37..4e4226aba 100644 --- a/tests/engine_polars/test_flex_2day_stochastic_dispatch_no_storage.py +++ b/tests/engine_polars/test_flex_2day_stochastic_dispatch_no_storage.py @@ -39,17 +39,21 @@ def test_2day_stochastic_dispatch_no_storage_parity(scenario_workdir): assert data.pd_branch_weight is not None assert data.dt_non_anticipativity is not None assert data.period_branch_full is not None - # 4 branches with weight 0.25 each — siblings sum to 1.0 per (d, t). + # Genuine 4-forecast fan (realized retagged to a forecast) at weight + # 0.25 each; the real-named trunk period1 is a weight-0 phantom. weights = data.pdt_branch_weight.frame - sample = (weights + # The trunk period1 row is present but weight 0 (phantom). + trunk = (weights .filter((pl.col("d") == "period1") & (pl.col("t") == "t0001"))) - assert sample.height == 1 - # Single (d=period1, t=t0001) row — siblings sit on (d=period1_upper, t), - # etc. Sum across siblings sharing the same time index must be 1.0. + assert trunk.height == 1 + assert abs(float(trunk["value"].sum())) < 1e-12 + # The four FORECAST siblings sit on the suffixed periods and their + # weights sum to 1.0 per time index. siblings_t1 = (weights .filter(pl.col("t") == "t0001") .filter(pl.col("d").is_in([ - "period1", "period1_upper", "period1_lower", "period1_mid"]))) + "period1_realized", "period1_upper", + "period1_lower", "period1_mid"]))) assert abs(float(siblings_t1["value"].sum()) - 1.0) < 1e-12 pb = Problem() diff --git a/tests/engine_polars/test_invest_na_window.py b/tests/engine_polars/test_invest_na_window.py index afc221295..563d5d308 100644 --- a/tests/engine_polars/test_invest_na_window.py +++ b/tests/engine_polars/test_invest_na_window.py @@ -119,18 +119,23 @@ def test_main_objective_hand_calc(na_window_wf) -> None: @pytest.mark.solver def test_main_invest_shared_first_stage(na_window_resolved) -> None: - """T-Main (§9.2): p2035 v_invest is EQUAL across branches (shared - first stage = 60), while p2040 diverges (recourse: r_rlz = 60, - r_low = 0).""" + """T-Main (§9.2), R3 re-grounded: the first-stage p2035 v_invest is + EQUAL across the two forecast branches (shared first stage = 60: + p2035_high == p2035_low), while p2040 diverges (recourse: r_high = 60 + at p2040_high, r_low = 0 at p2040_low). The weight-0 phantom trunk + p2040 carries 0.""" _data, sol, _pb = na_window_resolved vi = sol.value("v_invest_p") got = {r["d"]: round(float(r["value"]), 4) for r in vi.iter_rows(named=True)} - assert got["p2035"] == pytest.approx(60.0) + assert got["p2035_high"] == pytest.approx(60.0) # shared first stage assert got["p2035_low"] == pytest.approx(60.0) # shared first stage - assert got["p2035"] == got["p2035_low"] - assert got["p2040"] == pytest.approx(60.0) # recourse (high) + assert got["p2035_high"] == got["p2035_low"] + assert got["p2040_high"] == pytest.approx(60.0) # recourse (high) assert got["p2040_low"] == pytest.approx(0.0) # recourse (low) + # The weight-0 phantom recourse trunk p2040 is NOT in the invest-NA + # window and is uncosted — degenerate, so only bounded-feasible. + assert 0.0 <= got["p2040"] <= 150.0 @pytest.mark.solver @@ -157,14 +162,15 @@ def test_ws_control_and_evpi_gap(na_ws_wf, na_window_wf) -> None: @pytest.mark.solver def test_frame_window_membership(na_window_resolved) -> None: - """T-Frame (§3.1): pd_non_anticipativity = {(p2035, p2035_low)} — - the window ties only the p2035 anchor's synthetic copy; p2040 is - NOT in the window so (p2040, p2040_low) is absent.""" + """T-Frame (§3.1), R3 re-grounded: pd_non_anticipativity = + {(p2035, p2035_high), (p2035, p2035_low)} — the window ties the p2035 + anchor across BOTH forecast copies of the genuine {high, low} fan; + p2040 is NOT in the window so its branch pairs are absent.""" data, _sol, _pb = na_window_resolved pdna = data.pd_non_anticipativity pairs = {(str(r["d"]), str(r["b"])) for r in pdna.iter_rows(named=True)} - assert pairs == {("p2035", "p2035_low")} + assert pairs == {("p2035", "p2035_high"), ("p2035", "p2035_low")} def test_frame_empty_when_window_unset(na_ws_wf) -> None: @@ -179,30 +185,35 @@ def test_frame_empty_when_window_unset(na_ws_wf) -> None: @pytest.mark.solver def test_branch_weights_partition(na_window_resolved) -> None: - """T-Weight (F7, §9.4): the tied cohort's weights are a probability - partition — pd_branch_weight[p2035] = 0.25, [p2035_low] = 0.75, - summing to 1.0 across the tie (so the shared decision is costed once - at full weight — the Option-B equivalence arithmetic).""" + """T-Weight (F7, §9.4), R3 re-grounded: under forecasts-only the tied + cohort's weights are a probability partition over the FORECASTS — + pd_branch_weight[p2035_high] = 0.25, [p2035_low] = 0.75, summing to + 1.0 across the fan (so the shared decision is costed once at full + weight — the Option-B equivalence arithmetic). The real-named trunk + p2035 is a weight-0 phantom.""" data, _sol, _pb = na_window_resolved df = data.pd_branch_weight.frame val_col = [c for c in df.columns if c != "d"][-1] w = {str(r["d"]): round(float(r[val_col]), 6) for r in df.iter_rows(named=True)} - assert w["p2035"] == pytest.approx(0.25) + assert w["p2035"] == pytest.approx(0.0) + assert w["p2035_high"] == pytest.approx(0.25) assert w["p2035_low"] == pytest.approx(0.75) @pytest.mark.solver def test_invest_constraint_rows(na_window_resolved) -> None: - """T-C (§9.4): non_anticipativity_invest_p ties (base, p2035, - p2035_low); NO row over p2040.""" + """T-C (§9.4), R3 re-grounded: non_anticipativity_invest_p ties + (base, p2035, p2035_high) and (base, p2035, p2035_low) — both + forecasts of the genuine fan; NO row over p2040.""" _data, _sol, pb = na_window_resolved names = pb.cstr_names() assert "non_anticipativity_invest_p" in names over = pb.cstrs_named("non_anticipativity_invest_p")[0].over triples = {(str(r["p"]), str(r["d"]), str(r["b"])) for r in over.iter_rows(named=True)} - assert triples == {("base", "p2035", "p2035_low")} + assert triples == {("base", "p2035", "p2035_high"), + ("base", "p2035", "p2035_low")} # --- T-Divest (solver, MANDATORY — F2) ------------------------------------- @@ -210,8 +221,9 @@ def test_invest_constraint_rows(na_window_resolved) -> None: @pytest.mark.solver def test_divest_family_ties_first_period(na_divest_resolved) -> None: - """T-Divest (§4.4, F2): a divest-eligible base under recourse+window - → non_anticipativity_divest_p ties the first-period divest over + """T-Divest (§4.4, F2), R3 re-grounded: a divest-eligible base under + recourse+window → non_anticipativity_divest_p ties the first-period + divest over both forecasts (base, p2035, p2035_high) and (base, p2035, p2035_low).""" _data, _sol, pb = na_divest_resolved names = pb.cstr_names() @@ -219,23 +231,26 @@ def test_divest_family_ties_first_period(na_divest_resolved) -> None: over = pb.cstrs_named("non_anticipativity_divest_p")[0].over triples = {(str(r["p"]), str(r["d"]), str(r["b"])) for r in over.iter_rows(named=True)} - assert triples == {("base", "p2035", "p2035_low")} + assert triples == {("base", "p2035", "p2035_high"), + ("base", "p2035", "p2035_low")} @pytest.mark.solver def test_divest_net_capacity_equal_across_branches(na_divest_resolved) -> None: - """T-Divest (§4.4): the NET first-stage capacity - (existing + invest − divest) is EQUAL across branches at p2035 — - the tie shares the net decision, not just gross invest.""" + """T-Divest (§4.4), R3 re-grounded: the NET first-stage capacity + (existing + invest − divest) is EQUAL across the two forecast branches + at p2035 — the tie shares the net decision, not just gross invest.""" _data, sol, _pb = na_divest_resolved inv = {r["d"]: float(r["value"]) for r in sol.value("v_invest_p").iter_rows(named=True)} div = {r["d"]: float(r["value"]) for r in sol.value("v_divest_p").iter_rows(named=True)} existing = 50.0 - net_rlz = existing + inv.get("p2035", 0.0) - div.get("p2035", 0.0) - net_low = existing + inv.get("p2035_low", 0.0) - div.get("p2035_low", 0.0) - assert net_rlz == pytest.approx(net_low, abs=1e-6) + net_high = (existing + inv.get("p2035_high", 0.0) + - div.get("p2035_high", 0.0)) + net_low = (existing + inv.get("p2035_low", 0.0) + - div.get("p2035_low", 0.0)) + assert net_high == pytest.approx(net_low, abs=1e-6) # --- T-None (graceful no-op) ----------------------------------------------- diff --git a/tests/engine_polars/test_period_walk_lineage.py b/tests/engine_polars/test_period_walk_lineage.py index cec680102..c5e221137 100644 --- a/tests/engine_polars/test_period_walk_lineage.py +++ b/tests/engine_polars/test_period_walk_lineage.py @@ -466,8 +466,10 @@ def test_w6_edd_invest_set_today_bug_witness(stoch_wf) -> None: out.select(["e", "d_invest", "d"]).rows()} expected = { (WIND, "p2035", "p2035"), + (WIND, "p2035", "p2035_high"), # genuine {high, low} fan (R3) (WIND, "p2035", "p2035_low"), # the surviving year-0 channel (WIND, "p2035", "p2040"), + (WIND, "p2035", "p2040_high"), # passes at fill-year 0 (§6.4) (WIND, "p2035", "p2040_low"), # passes at fill-year 0 (§6.4) (WIND, "p2040", "p2040"), } @@ -475,7 +477,9 @@ def test_w6_edd_invest_set_today_bug_witness(stoch_wf) -> None: # Erratum E1 today-bug witness: under correct byte-copied years # this pair WOULD be present; today's walker overwrite drops it. # Slice D's year-threading prerequisite resolves it — re-point here. + # (Both forecast continuation channels are dropped symmetrically.) assert (WIND, "p2040", "p2040_low") not in got + assert (WIND, "p2040", "p2040_high") not in got # --- W7 — non-vacuity + repeat-determinism pins ------------------------ @@ -689,17 +693,36 @@ def test_p3a_unequal_chain_weights(tmp_path) -> None: def test_p3b_cohort_sum_violation(tmp_path) -> None: - """P3b (§9.3): a cohort whose weights do not sum to 1 → message - (ii). Distinct first-timesteps make each branch its own - normalization group, so the cohort weights sum > 1.""" - ft = [("p2035", "t0001"), ("p2035_low", "t0002"), - ("p2040", "t0004"), ("p2040_low", "t0005")] - sbtb = [("p2035", "a"), ("p2035_low", "b"), - ("p2040", "c"), ("p2040_low", "d")] + """P3b (§9.3), R4 re-grounded for forecasts-only: obligation (ii) + (cohort forecast weights sum to 1) must still FIRE on a genuinely + malformed cohort. + + Under the forecasts-only rule the producer renormalises each + first-step forecast GROUP to 1 and zeroes the phantom trunk, so a + well-formed cohort trivially sums to 1 (the old "distinct first-step" + trick now yields a phantom-0 + single-lone-forecast-at-1.0 cohort that + sums to exactly 1 — a tautology). To keep this a REAL negative test + we hand-build a MALFORMED cohort: one phantom anchor ``p2035`` with + TWO forecast members ``p2035_a`` / ``p2035_b`` that sit in DIFFERENT + first-step normalization groups (``p2035_b`` reveals at ``t0002``, not + ``t0001``). Each forecast then normalises to 1.0 inside its own + singleton group, so the producer emits + ``{p2035:0, p2035_a:1.0, p2035_b:1.0}`` and the cohort sums to 2.0 ≠ 1 + — exactly the producer bug obligation (ii) guards against.""" + pb = [("p2035", "p2035"), + ("p2035", "p2035_a"), ("p2035", "p2035_b")] + piu = ["p2035", "p2035_a", "p2035_b"] + sbtb = [("p2035", "ref"), ("p2035_a", "a"), ("p2035_b", "b")] + bw = [("p2035_a", 1.0), ("p2035_b", 1.0)] + ft = [("p2035", "t0001"), ("p2035_a", "t0001"), + ("p2035_b", "t0002")] # p2035_b in a DIFFERENT first-step group + pyd = [("p2035", 0.0), ("p2035_a", 0.0), ("p2035_b", 0.0)] + pyr = [("p2035", "0", 1.0), ("p2035_a", "0", 1.0), + ("p2035_b", "0", 1.0)] v = check_recourse_npv_preconditions( tmp_path, provider=_mk_provider( - pb=_PB, piu=_PIU, sbtb=sbtb, bw=_BW, ft=ft, - pyd=_PYD, pyr=_PYR)) + pb=pb, piu=piu, sbtb=sbtb, bw=bw, ft=ft, + pyd=pyd, pyr=pyr)) assert any("obligation (ii)" in m for m in v), v diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py index b2ea29576..34126313d 100644 --- a/tests/engine_polars/test_recourse_invest_gate.py +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -3,22 +3,26 @@ Design: ``specs/sliceD_recourse_invest_design.md`` §15.1 (variant A) / §18 D5. Fixture ``stoch_two_period_invest.json`` (built via -``build_stoch_two_period_invest.py``): two 3-step periods, branches -rlz w=0.25 / low w=0.75, deterministic demand 100/130 MW, an existing -wind unit whose branch-varying ``upper_limit`` profile (rlz 0.40/0.40, -low 0.20/0.10 through the ``group__unit`` stochastic group) leaves the -investable ``peaker`` (unitsize 1, invest_cost 1, discount_rate 0.05, -lifetime 1 → annuity 1050 exactly; unbounded-forward windows 2100/1050) -residual demand rlz 60/90, low 80/120. Under ``recourse`` the invest -axis fans, each leaf invests its own capacity, and the objective is the +``build_stoch_two_period_invest.py``): two 3-step periods, a genuine +2-forecast fan ``high`` w=1 / ``low`` w=3 (forecasts-only, n_real = 0), +deterministic demand 100/130 MW, an existing wind unit whose +branch-varying ``upper_limit`` profile (high 0.40/0.40, low 0.20/0.10 +through the ``group__unit`` stochastic group) leaves the investable +``peaker`` (unitsize 1, invest_cost 1, discount_rate 0.05, lifetime 1 → +annuity 1050 exactly; unbounded-forward windows 2100/1050) residual +demand high 60/90, low 80/120. This is a FAN-AT-FIRST reveal (branches +declared at the first period p2035), so under the forecasts-only model +the reference forecast owns the real period names; the real-named trunk +periods p2035/p2040 are weight-0 phantoms (pd_branch_weight = 0) and the +investment fans per forecast onto p2035_high/p2040_high (weight 0.25) and +p2035_low/p2040_low (weight 0.75). The objective is the probability-weighted expectation: 0.25·(60·2100 + 30·1050) + 0.75·(80·2100 + 40·1050) = 196 875. -Verified before pinning (design §15.1 verify-before-pin): -``ed_entity_annual_discounted[peaker, ·]`` = 2100/2100/1050/1050 and the -LP optimum is (60, 80, 30, 40) — empirically confirmed on the cascade -before these asserts were written. +Verified on a driven solve (R3 re-ground): per-forecast invest +{p2035_high:60, p2040_high:30, p2035_low:80, p2040_low:40}; the phantom +trunk p2035/p2040 invest 0; objective 196 875. """ from __future__ import annotations @@ -30,21 +34,32 @@ OBJECTIVE = 196_875.0 SOLVE = "stoch_2p_inv" -# ed_entity_annual_discounted[peaker, d] (§15.1 step 2). +# ed_entity_annual_discounted[peaker, d] (§15.1 step 2) — the annuity is a +# per-period window property, identical on the phantom trunk and both +# forecast copies (2100 at the branching period p2035, 1050 at the +# continuation period p2040). EXPECTED_ANNU = { "p2035": 2100.0, "p2040": 1050.0, + "p2035_high": 2100.0, "p2040_high": 1050.0, "p2035_low": 2100.0, "p2040_low": 1050.0, } +# Forecasts-only weights (R2): phantom trunk → 0, forecasts renormalised +# {high:0.25, low:0.75}. EXPECTED_WEIGHT = { - "p2035": 0.25, "p2040": 0.25, "p2035_low": 0.75, "p2040_low": 0.75, + "p2035": 0.0, "p2040": 0.0, + "p2035_high": 0.25, "p2040_high": 0.25, + "p2035_low": 0.75, "p2040_low": 0.75, } # Objective coefficient of v_invest[peaker, d] = unitsize·annu[d]·weight[d] -# (§15.2 D4): 525 / 262.5 / 1575 / 787.5. +# (§15.2 D4): phantom 0; high 525 / 262.5; low 1575 / 787.5. EXPECTED_COEFF = {d: EXPECTED_ANNU[d] * EXPECTED_WEIGHT[d] for d in EXPECTED_ANNU} -# Per-leaf optimal invest (§15.1 step 3). +# Per-leaf optimal invest (§15.1 step 3): the phantom trunk invests 0; each +# forecast leaf invests its own capacity. EXPECTED_INVEST = { - "p2035": 60.0, "p2040": 30.0, "p2035_low": 80.0, "p2040_low": 40.0, + "p2035": 0.0, "p2040": 0.0, + "p2035_high": 60.0, "p2040_high": 30.0, + "p2035_low": 80.0, "p2040_low": 40.0, } @@ -96,12 +111,25 @@ def test_recourse_objective_hand_calc(recourse_wf): @pytest.mark.solver def test_recourse_per_leaf_invest(_resolved): - """§15.2 D2: v_invest carries all four (peaker, d) columns; each leaf - invests its own §15.1 quantities (60/30 realized, 80/40 low).""" + """§15.2 D2: v_invest carries a (peaker, d) column per period — each + forecast leaf invests its own §15.1 quantities (high 60/30, low 80/40). + + The real-named trunk periods p2035/p2040 are weight-0 PHANTOMS: their + invest objective coefficient AND demand penalty are both 0, so their + invest value is a genuine alternate-optimum (degenerate — the simplex + vertex varies with build context). We pin the economically-determined + forecast leaves exactly and assert the phantoms are merely feasible.""" _data, sol = _resolved vi = sol.value("v_invest_p") got = {r["d"]: float(r["value"]) for r in vi.iter_rows(named=True)} - assert got == pytest.approx(EXPECTED_INVEST) + forecast_keys = ("p2035_high", "p2040_high", "p2035_low", "p2040_low") + forecast_got = {k: got[k] for k in forecast_keys} + assert forecast_got == pytest.approx( + {k: EXPECTED_INVEST[k] for k in forecast_keys}) + # Weight-0 phantom trunk periods are unpinned (degenerate) — only + # bounded-feasible (0 ≤ v ≤ invest_max_total 150). + assert 0.0 <= got["p2035"] <= 150.0 + assert 0.0 <= got["p2040"] <= 150.0 @pytest.mark.solver @@ -134,20 +162,31 @@ def test_recourse_edd_lineage_filtered(_resolved): for r in data.edd_invest_set.iter_rows(named=True)} assert rows == { ("p2035", "p2035"), ("p2035", "p2040"), + ("p2035_high", "p2035_high"), ("p2035_high", "p2040_high"), ("p2035_low", "p2035_low"), ("p2035_low", "p2040_low"), - ("p2040", "p2040"), ("p2040_low", "p2040_low"), + ("p2040", "p2040"), ("p2040_high", "p2040_high"), + ("p2040_low", "p2040_low"), } @pytest.mark.solver def test_recourse_committed_output_realized_only(recourse_wf): - """§11.1: the committed v_invest output carries ONLY the realized - periods (real names) — branch rows are horizon-output-only (D8).""" + """§11.1, R3 re-grounded (forecasts-only, fan-at-first): the committed + v_invest output carries ONLY the real-named (trunk) periods — branch + rows are horizon-output-only (D8). This fixture is FAN-AT-FIRST: the + reveal is at the first period p2035, so there is NO pre-reveal + here-and-now committed stage — every investment is a per-forecast + wait-and-see decision on the branch rows (p2035_high/low, p2040_high/ + low). The real-named trunk periods p2035/p2040 are weight-0 phantoms, + so the committed (realized-only) investment is 0 on both: there is no + non-anticipative here-and-now capacity to commit. The actual + per-forecast investment (high 60/30, low 80/40) is surfaced only under + ``output_horizon`` (see test_recourse_output_horizon_branch_rows).""" inv = pl.read_parquet( recourse_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") got = {r["period"]: round(float(r["peaker"]), 6) for r in inv.iter_rows(named=True)} - assert got == {"p2035": 60.0, "p2040": 30.0} + assert got == {"p2035": 0.0, "p2040": 0.0} @pytest.fixture(scope="module") @@ -160,29 +199,34 @@ def horizon_wf(scenario_workdir): @pytest.mark.solver def test_recourse_output_horizon_branch_rows(horizon_wf): - """§11.2 / §15.5 (D8): under ``output_horizon`` the invest-axis output - surfaces the non-realized branch rows (80 at p2035_low, 40 at - p2040_low) alongside the committed realized rows (60 / 30).""" + """§11.2 / §15.5 (D8), R3 re-grounded: under ``output_horizon`` the + invest-axis output surfaces every per-forecast branch row — the high + fan (60 at p2035_high, 30 at p2040_high) and the low fan (80 at + p2035_low, 40 at p2040_low). The weight-0 phantom trunk p2035/p2040 + carries 0 (no here-and-now investment under fan-at-first).""" inv = pl.read_parquet( horizon_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") got = {r["period"]: round(float(r["peaker"]), 6) for r in inv.iter_rows(named=True)} - assert got.get("p2035") == 60.0 - assert got.get("p2040") == 30.0 + assert got.get("p2035") == 0.0 + assert got.get("p2040") == 0.0 + assert got.get("p2035_high") == 60.0 + assert got.get("p2040_high") == 30.0 assert got.get("p2035_low") == 80.0 assert got.get("p2040_low") == 40.0 @pytest.mark.solver def test_recourse_per_path_cap_two_leaf_rows(_resolved): - """§15.1 / §7.2 (D6): under recourse the entity total cap fans into - ONE row per scenario leaf (peaker × {__realized, low}) under the - ``…_path`` name — the legacy single-row ``maxInvest_entity_total`` - (which summed all four periods, 210 > 150, and would spuriously - bind) is gone. Each leaf row sums only its two periods; the D2 - per-leaf-invest test pins those sums to 90 (60+30) and 120 (80+40), - both ≤ 150 → non-binding, hence objective stays 196 875. The naive - all-``d`` sum 210 asymmetry is the proof the caps are per-path.""" + """§15.1 / §7.2 (D6), R3 re-grounded: under recourse the entity total + cap fans into ONE row per scenario leaf (peaker × {__realized, high, + low} of the genuine {high, low} fan) under the ``…_path`` name — the + legacy single-row ``maxInvest_entity_total`` (which summed all periods, + 210 > 150, and would spuriously bind) is gone. Each leaf row sums only + its own periods; the D2 per-leaf-invest test pins the forecast sums to + 90 (high 60+30) and 120 (low 80+40), both ≤ 150 → non-binding, hence + objective stays 196 875. The naive all-``d`` sum 210 asymmetry is the + proof the caps are per-path.""" data, _sol = _resolved from flextool.engine_polars import build_flextool from polar_high import Problem @@ -194,19 +238,23 @@ def test_recourse_per_path_cap_two_leaf_rows(_resolved): assert "maxInvest_entity_total" not in names # legacy replaced recs = pb.cstrs_named("maxInvest_entity_total_path") over = recs[0].over - assert over.height == 2 # (peaker, __realized), (peaker, low) + assert over.height == 3 # (peaker, {__realized, high, low}) leaves = sorted(str(v) for v in over["leaf"].to_list()) - assert leaves == ["__realized", "low"] + assert leaves == ["__realized", "high", "low"] ents = {str(v) for v in over["p"].to_list()} assert ents == {"peaker"} @pytest.mark.solver def test_recourse_handoff_drops_non_realized(_resolved, recourse_wf, caplog): - """§10.1 (D7): the handoff pre-filter drops branch-named v_invest rows - (non-realized scenario) BEFORE the commit loop, debug-logging the - dropped mass (low leaf 80 + 40 = 120), and commits only real periods - (60 / 30). Guard 3 stays inert (no raise).""" + """§10.1 (D7), R3 re-grounded (forecasts-only fan): the handoff + pre-filter drops ALL branch-named v_invest rows (both forecasts) BEFORE + the commit loop, debug-logging the dropped mass (high 60+30 + low 80+40 + = 210 across 4 branch periods), and commits only the real-named periods. + Under fan-at-first the committed real-named capacity is 0 (the trunk is + a weight-0 phantom — the here-and-now has no non-anticipative invest), + but the point of this test is that the branch rows are structurally + dropped from the handoff. Guard 3 stays inert (no raise).""" import logging data, sol = _resolved @@ -218,16 +266,19 @@ def test_recourse_handoff_drops_non_realized(_resolved, recourse_wf, caplog): sol, recourse_wf, SOLVE, flex_data=data) assert handoff is not None ri = handoff.realized_invest - periods = {str(r["period"]) for r in ri.iter_rows(named=True) - if float(r["value"]) > 0} + periods = {str(r["period"]) for r in ri.iter_rows(named=True)} + # Only real-named trunk periods survive the pre-filter — no branch rows. assert periods == {"p2035", "p2040"} - assert not (periods & {"p2035_low", "p2040_low"}) - # The pre-filter debug-logged the dropped low-branch mass = 120. + assert not (periods & {"p2035_high", "p2040_high", + "p2035_low", "p2040_low"}) + # The pre-filter debug-logged the dropped branch mass = 210 across all + # four forecast branch periods. msgs = [r.getMessage() for r in caplog.records if "non-realized invest" in r.getMessage()] assert msgs, "pre-filter debug log not emitted" - assert "120" in msgs[0] - assert "p2035_low" in msgs[0] and "p2040_low" in msgs[0] + assert "210" in msgs[0] + for b in ("p2035_high", "p2040_high", "p2035_low", "p2040_low"): + assert b in msgs[0] @pytest.mark.solver diff --git a/tests/engine_polars/test_recourse_invest_hedge.py b/tests/engine_polars/test_recourse_invest_hedge.py index 44a165e3b..e845210b6 100644 --- a/tests/engine_polars/test_recourse_invest_hedge.py +++ b/tests/engine_polars/test_recourse_invest_hedge.py @@ -9,7 +9,7 @@ period-Map ``invest_cost`` {p2035: 1, p2040: 3} so the first stage is cheaper per delivered p2040-MW; a free ``wind`` unit's branch-varying ``upper_limit`` profile carves the residual the investable ``base`` unit -must cover (p2035 = 40 shared; p2040 realized/high = 120, low = 60). +must cover (p2035 = 40 shared; p2040 high = 120, low = 60). Three scenarios on identical scenario data: * ``hedge`` — mid-horizon reveal at p2040 (RP = 173 250). @@ -315,14 +315,28 @@ def test_rp_objective_hand_calc(hedge_wf) -> None: def test_rp_invest_is_a_genuine_hedge(hedge_resolved) -> None: """T-RP (§8.2): the shared first stage x = v_invest[base, p2035] = 60 (a hedge between D2_low = 60 and D2_high = 120, above D1 = 40), with - recourse r_rlz = 60 at p2040 and r_low = 0 at p2040_low.""" + recourse r_high = 60 at p2040_high and r_low = 0 at p2040_low. + + Under the forecasts-only model (genuine {high, low} fan, n_real = 0) + the reference forecast owns the real period names, so the recourse + period's real-named trunk p2040 is a weight-0 PHANTOM: its invest + objective coefficient AND its demand penalty are both 0, so its + invest value is a genuine alternate-optimum (degenerate — both 0 and + 60 are cost-identical, and the simplex vertex picked varies with build + context). We therefore pin only the economically-determined + variables (the costed forecasts + the pre-reveal shared stage) and + assert the phantom is merely feasible, not a specific value.""" _data, sol, _pb = hedge_resolved vi = sol.value("v_invest_p") got = {r["d"]: round(float(r["value"]), 4) for r in vi.iter_rows(named=True)} - assert got == pytest.approx({ - "p2035": 60.0, "p2040": 60.0, "p2040_low": 0.0, - }) + # Economically pinned (costed) decisions. + assert got["p2035"] == pytest.approx(60.0) # shared first stage + assert got["p2040_high"] == pytest.approx(60.0) # recourse (high) + assert got["p2040_low"] == pytest.approx(0.0) # recourse (low) + # The weight-0 phantom recourse trunk is unpinned (degenerate) — only + # bounded-feasible (0 ≤ v ≤ invest_max_total 150). + assert 0.0 <= got["p2040"] <= 150.0 @pytest.mark.solver @@ -376,14 +390,16 @@ def test_lineage_shared_trunk_on_every_leaf(hedge_resolved) -> None: @pytest.mark.solver def test_d_leaf_is_a_relation_not_a_partition(hedge_resolved) -> None: """T-C / T-L (§6.2): d_leaf is a RELATION — the shared pre-reveal - anchor p2035 is on BOTH the __realized leaf and the low leaf, so each - scenario path's total cap counts it. A strict partition would put - p2035 on __realized only (under-counting the low path).""" + anchor p2035 is on EVERY leaf (__realized, high, low) of the genuine + {high, low} fan, so each scenario path's total cap counts it. A strict + partition would put p2035 on one leaf only (under-counting the others). + """ data, _sol, _pb = hedge_resolved dl = {(str(r["d"]), str(r["leaf"])) for r in data.d_leaf.iter_rows(named=True)} assert dl == { ("p2035", "__realized"), ("p2040", "__realized"), + ("p2035", "high"), ("p2040_high", "high"), ("p2035", "low"), ("p2040_low", "low"), } @@ -405,52 +421,53 @@ def test_pd_non_anticipativity_empty_no_invest_na(hedge_resolved) -> None: @pytest.mark.solver def test_per_path_cap_two_leaf_rows(hedge_resolved) -> None: """T-C (§6.2 / §8.3): the entity total cap fans into ONE row per - scenario leaf (base × {__realized, low}) under the ``…_path`` name; - the legacy single-row ``maxInvest_entity_total`` (which would sum all - three periods) is gone.""" + scenario leaf (base × {__realized, high, low} of the genuine {high, + low} fan) under the ``…_path`` name; the legacy single-row + ``maxInvest_entity_total`` (which would sum all periods) is gone.""" _data, _sol, pb = hedge_resolved names = pb.cstr_names() assert "maxInvest_entity_total_path" in names assert "maxInvest_entity_total" not in names recs = pb.cstrs_named("maxInvest_entity_total_path") over = recs[0].over - assert over.height == 2 + assert over.height == 3 assert sorted(str(v) for v in over["leaf"].to_list()) == [ - "__realized", "low", + "__realized", "high", "low", ] assert {str(v) for v in over["p"].to_list()} == {"base"} @pytest.mark.solver def test_committed_output_realized_only(hedge_wf) -> None: - """§9.4: the committed v_invest output carries only the realized - (trunk) periods with real names — the shared first stage p2035 = 60 - and the realized recourse p2040 = 60; branch rows are dropped.""" + """§9.4 (forecasts-only, owner decision 2): the committed v_invest + output carries only the real-named (trunk) periods — branch rows are + dropped. The committed INVESTMENT is the single here-and-now + PRE-REVEAL decision: the shared first stage p2035 = 60. The recourse + period p2040 is the weight-0 phantom trunk (pd_branch_weight = 0), so + it carries no committed investment (= 0) — the per-forecast recourse + lives on the branch rows p2040_high / p2040_low, which the committed + output does not surface.""" inv = pl.read_parquet( hedge_wf / "output_raw" / f"v_invest__{SOLVE}.parquet") got = {r["period"]: round(float(r["base"]), 6) for r in inv.iter_rows(named=True)} - assert got == {"p2035": 60.0, "p2040": 60.0} + assert got == {"p2035": 60.0, "p2040": 0.0} # --- T-S (storage): NA fires + branch copy links to shared trunk ----------- @pytest.mark.solver -def test_storage_na_vacuous_on_single_forecast_reveal(hedge_storage_wf) -> None: - """T-S (§5.1, §9.1), re-grounded: the hedge fixture's reveal declares a - SINGLE forecast (``low``; ``rlz`` is the realised designation = the high - scenario), so under the corrected forecast-to-forecast model there is - nothing to tie — a single forecast has no sibling — and the storage NA - family is correctly VACUOUS (the old trunk-anchored tie - ``(p2040, p2040_low)`` is gone; the realised trunk is never an NA hub). - - This pins the degenerate-fixture behaviour: slice R3 (genuine 2-forecast - fan + re-derived RP/WS/EVPI/VSS/EEV hand-calcs) will add a second - forecast and restore a forecast-to-forecast tie — at which point this - assertion is updated to the forecast star. Until then a single-forecast - reveal emitting NO NA constraint is the correct, witnessed property. - """ +def test_storage_na_forecast_star_on_two_forecast_reveal( + hedge_storage_wf) -> None: + """T-S (§5.1, §9.1), R3 re-grounded: the hedge fixture is now a genuine + 2-forecast fan (``high``, ``low``; n_real = 0) revealing at p2040, so + under the forecasts-only model the storage non-anticipativity family + ties the two forecasts forecast-to-forecast at the reveal step — the + branch copies ``p2040_high`` and ``p2040_low`` are pinned to share the + pre-reveal net-charge. The tie is NOT anchored on the weight-0 phantom + trunk ``p2040`` (the realised/reference trunk is never an NA hub); the + star is the forecast pair only.""" from polar_high import Problem from flextool.engine_polars import build_flextool, load_flextool @@ -458,16 +475,14 @@ def test_storage_na_vacuous_on_single_forecast_reveal(hedge_storage_wf) -> None: pb = Problem() build_flextool(pb, data) names = pb.cstr_names() - # A single forecast → the per-anchor-group star is skipped (<2 - # forecasts) → no family emitted. If a future change re-introduces a - # trunk-anchored tie this assertion fails loudly. - if "non_anticipativity_storage_use" in names: - over = pb.cstrs_named("non_anticipativity_storage_use")[0].over - pairs = {(str(r["d"]), str(r["b"])) - for r in over.iter_rows(named=True)} - assert not pairs, ( - "single-forecast reveal must not emit an NA tie (forecast-to-" - f"forecast is vacuous with one forecast); got {pairs}") + assert "non_anticipativity_storage_use" in names, ( + "a genuine 2-forecast reveal must emit a forecast-to-forecast " + "storage NA tie") + over = pb.cstrs_named("non_anticipativity_storage_use")[0].over + pairs = {(str(r["d"]), str(r["b"])) + for r in over.iter_rows(named=True)} + assert pairs == {("p2040_high", "p2040_low")}, ( + f"forecast-to-forecast star expected; got {pairs}") def test_storage_branch_copy_predecessor_is_shared_trunk( @@ -496,7 +511,7 @@ def test_make_step_jump_dtttdt_parity_midhorizon(hedge_wf) -> None: sp = pl.read_csv(hedge_wf / "solve_data" / "step_previous.csv") assert sp.height == len(dtttdt) first_steps = {("p2035", "t0001"), ("p2040", "t0004"), - ("p2040_low", "t0004")} + ("p2040_high", "t0004"), ("p2040_low", "t0004")} for r in sp.rows(named=True): key = (r["period"], r["time"]) m = dtttdt[key] diff --git a/tests/engine_polars/test_single_invest_stochastic_ops.py b/tests/engine_polars/test_single_invest_stochastic_ops.py new file mode 100644 index 000000000..73887c76d --- /dev/null +++ b/tests/engine_polars/test_single_invest_stochastic_ops.py @@ -0,0 +1,184 @@ +"""Single here-and-now invest + stochastic operational fan (the DEFAULT +stochastic model's defining structure). + +Owner decision 6 / the Q1 coverage gap: under the DEFAULT +``stochastic_invest_method = none``, a solve carrying BOTH an invest +decision AND a genuinely divergent operational forecast fan must produce + + * a SINGLE here-and-now ``v_invest`` per REAL period — one unambiguous + capacity decision, NOT branch-suffixed copies (that would be the + opt-in ``recourse`` wait-and-see structure, which this test must + genuinely reject); while + * ``v_flow`` fans across the forecast branches (the branch-suffixed + forecast periods own their own operational LP variables and the + forecasts genuinely diverge); and + * the solve is Optimal. + +This is the "single here-and-now invest + stochastic ops" structure — the +engine default the owner wanted — as opposed to recourse (per-branch +post-reveal invest), which is NOT exercised here. + +Witness fixture: ``w4c_divergent_dispatch.json`` — the W4c parent +``stoch_rd`` is a genuinely stochastic INVEST solve (peaker ``invest_total``) +on the DEFAULT ``none`` invest method, with branch-DISTINCT wind +(rlz 0.50 / high 0.80 / low 0.10) so the forecasts genuinely diverge +(peaker 20 MW on the high-wind forecast, 90 MW on the low-wind forecast). +We read the parent solve's raw LP solution directly (the forecast-period +flow variables are LP variables regardless of output reporting), so the +assertions pin the LP structure, not an output-formatting choice. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import polars as pl +import pytest + +FLEXTOOL_ROOT = Path(__file__).resolve().parents[2] +FIXTURE_JSON = ( + FLEXTOOL_ROOT / "tests" / "fixtures" / "w4c_divergent_dispatch.json" +) + +SCENARIO = "w8_realized_dispatch" +PARENT = "stoch_rd" + +# Real (un-suffixed) period and its two forecast-branch copies. +REAL_PERIOD = "p2035" +FORECAST_PERIODS = {"p2035_high", "p2035_low"} +# Divergent peaker levels on the two forecasts (100 MW demand minus +# 0.80 / 0.10 wind of 100 MW existing). +PEAKER_HIGH_MW = 20.0 +PEAKER_LOW_MW = 90.0 + + +def _build_db(tmp_path: Path) -> str: + if not FIXTURE_JSON.exists(): + pytest.skip(f"fixture JSON not present: {FIXTURE_JSON}") + tests_dir = FLEXTOOL_ROOT / "tests" + if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) + from db_utils import json_to_db # noqa: E402 + from flextool.update_flextool.db_migration import migrate_database + + db_path = tmp_path / f"{FIXTURE_JSON.stem}.sqlite" + url = json_to_db(FIXTURE_JSON, db_path) + migrate_database(url) + return url + + +def _run_chain(tmp_path: Path): + from flextool.engine_polars import run_chain_from_db + + url = _build_db(tmp_path) + work_folder = tmp_path / "work" + work_folder.mkdir() + prev = os.environ.get("FLEXTOOL_AUTOSCALE_STRICT") + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = "1" + try: + steps = run_chain_from_db( + url, SCENARIO, work_folder=work_folder, + warm=False, keep_solutions=True, + ) + finally: + if prev is None: + os.environ.pop("FLEXTOOL_AUTOSCALE_STRICT", None) + else: + os.environ["FLEXTOOL_AUTOSCALE_STRICT"] = prev + assert steps, "run_chain_from_db produced no orchestration steps" + return steps, work_folder + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("single_invest_stochastic_ops") + return _run_chain(tmp_path) + + +def _parent(steps): + assert PARENT in steps, f"no {PARENT!r} step in {list(steps)}" + return steps[PARENT] + + +@pytest.mark.solver +def test_parent_optimal(chain) -> None: + """The combined invest + stochastic-ops solve is Optimal.""" + steps, _wf = chain + parent = _parent(steps) + assert parent.solution is not None and parent.optimal + + +@pytest.mark.solver +def test_invest_is_single_here_and_now(chain) -> None: + """``v_invest`` is SINGLE-valued per real period: it lives ONLY on the + real period ``p2035`` — there are NO branch-suffixed invest copies + (``p2035_high`` / ``p2035_low``). + + This genuinely fails if invest were to fan across forecast branches + (the opt-in recourse structure): a fanned invest would add + ``p2035_high`` / ``p2035_low`` rows to ``v_invest`` and the period set + would no longer be exactly ``{p2035}``.""" + steps, wf = chain + _parent(steps) # ensure the parent step ran + inv_path = wf / "output_raw" / f"v_invest__{PARENT}.parquet" + assert inv_path.exists(), f"missing {inv_path}" + inv = pl.read_parquet(inv_path) + assert "period" in inv.columns, ( + f"v_invest has no 'period' column: {inv.columns}" + ) + invest_periods = set(inv["period"].drop_nulls().to_list()) + # Exactly the real here-and-now period — no branch-suffixed copies. + assert invest_periods == {REAL_PERIOD}, ( + f"v_invest fanned across periods {sorted(invest_periods)}; " + f"expected a single here-and-now invest on {{{REAL_PERIOD}}}" + ) + assert not (invest_periods & FORECAST_PERIODS), ( + f"v_invest carries forecast-branch periods " + f"{sorted(invest_periods & FORECAST_PERIODS)} — invest must not fan " + f"under stochastic_invest_method=none" + ) + # Exactly one invested (entity, period) row — a single capacity decision. + assert inv.height == 1, ( + f"expected a single v_invest row (one here-and-now decision); " + f"got {inv.height}:\n{inv}" + ) + + +@pytest.mark.solver +def test_flow_fans_across_forecast_branches(chain) -> None: + """``v_flow`` fans across the forecast branches: both branch-suffixed + forecast periods carry operational variables and the peaker dispatch + genuinely diverges (20 MW on the high-wind forecast, 90 MW on the + low-wind forecast).""" + steps, _wf = chain + parent = _parent(steps) + flows = parent.solution.value("v_flow") + assert "d" in flows.columns, f"v_flow period column missing: {flows.columns}" + periods = set(flows["d"].unique().to_list()) + # The branch-suffixed forecast periods own operational LP variables. + assert FORECAST_PERIODS <= periods, ( + f"v_flow missing forecast-branch periods " + f"{sorted(FORECAST_PERIODS - periods)}; got {sorted(periods)}" + ) + + peaker = flows.filter(pl.col("source") == "peaker") + + def _level(period: str) -> set[float]: + return { + round(v, 6) + for v in peaker.filter(pl.col("d") == period)["value"].to_list() + } + + high = _level("p2035_high") + low = _level("p2035_low") + assert high == {PEAKER_HIGH_MW}, ( + f"high-forecast peaker {sorted(high)} != {{{PEAKER_HIGH_MW}}}" + ) + assert low == {PEAKER_LOW_MW}, ( + f"low-forecast peaker {sorted(low)} != {{{PEAKER_LOW_MW}}}" + ) + # The forecasts GENUINELY diverge (not a collapsed single value). + assert high != low, ( + "forecast branches did not diverge — v_flow is not a stochastic fan" + ) diff --git a/tests/engine_polars/test_stochastic_continuation_fanout.py b/tests/engine_polars/test_stochastic_continuation_fanout.py index 5694b0843..1ebf3bb8c 100644 --- a/tests/engine_polars/test_stochastic_continuation_fanout.py +++ b/tests/engine_polars/test_stochastic_continuation_fanout.py @@ -7,9 +7,14 @@ periods ``p2035`` (t0001-t0003) and ``p2040`` (t0004-t0006), demand 100 / 110 MW, a free wind unit capped by a branch-varying profile (stochastic group membership via ``group__unit`` only), and a -50 EUR/MWh thermal backstop. Branches ``rlz`` (realized, weight 1.0) +50 EUR/MWh thermal backstop. Forecast branches ``high`` (weight 1.0) and ``low`` (weight 3.0) are declared at ``p2035``/t0001, making -``p2040`` a CONTINUATION period. +``p2040`` a CONTINUATION period. Both branches are forecasts +(``realized:no``); n_real = 0 — a genuine ``{high, low}`` fan. Under the +forecasts-only model the reference forecast owns the real period names, +so the real-named trunk periods p2035/p2040 are weight-0 phantoms +(pd_branch_weight = 0) and the dispatch fans onto p2035_high/p2040_high +(0.25) and p2035_low/p2040_low (0.75). Hand calculation (design §5.2, uniform annualization factor 8760/3 = 2920, weights 0.25 / 0.75): @@ -45,54 +50,72 @@ P1_STEPS = ["t0001", "t0002", "t0003"] P2_STEPS = ["t0004", "t0005", "t0006"] -# The 12 (period, step) rows of design §4. +# The 18 (period, step) rows of the genuine {high, low} fan (§4, R3 +# re-grounded): the real-named trunk p2035/p2040 (phantom) plus both +# forecast copies high/low, each over 3 steps. EXPECTED_STEPS_IN_USE = ( [("p2035", t) for t in P1_STEPS] + + [("p2035_high", t) for t in P1_STEPS] + [("p2035_low", t) for t in P1_STEPS] + [("p2040", t) for t in P2_STEPS] + + [("p2040_high", t) for t in P2_STEPS] + [("p2040_low", t) for t in P2_STEPS] ) -# period__branch rows of design §4 (metadata fan incl. realized). +# period__branch rows (§4, R3 re-grounded): metadata fan — trunk self-row +# + both forecast members high/low per branching period. EXPECTED_PERIOD_BRANCH = [ ("p2035", "p2035"), - ("p2035", "p2035_rlz"), + ("p2035", "p2035_high"), ("p2035", "p2035_low"), ("p2040", "p2040"), - ("p2040", "p2040_rlz"), + ("p2040", "p2040_high"), ("p2040", "p2040_low"), ] +# Forecasts-only per-cohort weights (R2): phantom trunk → 0, forecasts +# renormalised {high:0.25, low:0.75}. EXPECTED_PD_BRANCH_WEIGHT = { - "p2035": 0.25, + "p2035": 0.0, + "p2035_high": 0.25, "p2035_low": 0.75, - "p2040": 0.25, + "p2040": 0.0, + "p2040_high": 0.25, "p2040_low": 0.75, } -# step_previous.csv rows of design §4 (7-column CSV shape). +# step_previous.csv rows of design §4 (7-column CSV shape), R3 re-grounded +# to the genuine {high, low} fan. EXPECTED_STEP_PREVIOUS_FIRST_ROWS = { # (period, time) -> (previous, previous_within_timeset, # previous_period, previous_within_solve, jump) ("p2035", "t0001"): ("t0003", "t0003", "p2040_low", "t0006", -5), + ("p2035_high", "t0001"): ("t0003", "t0003", "p2035_high", "t0003", -2), ("p2035_low", "t0001"): ("t0003", "t0003", "p2035_low", "t0003", -2), ("p2040", "t0004"): ("t0006", "t0006", "p2035", "t0003", 1), + ("p2040_high", "t0004"): ("t0006", "t0006", "p2035_high", "t0003", 1), ("p2040_low", "t0004"): ("t0006", "t0006", "p2035_low", "t0003", 1), } -# flex_data.dtttdt rows of design §4 (all 12; first-of-period rows +# flex_data.dtttdt rows of design §4 (all 18; first-of-period rows # carry the cross-period t_previous, unlike the CSV's within-period # cyclic ``previous`` column — pre-existing divergence, design §3.3). EXPECTED_DTTTDT = [ ("p2035", "t0001", "t0006", "t0003", "p2040_low", "t0006"), ("p2035", "t0002", "t0001", "t0001", "p2035", "t0001"), ("p2035", "t0003", "t0002", "t0002", "p2035", "t0002"), + ("p2035_high", "t0001", "t0003", "t0003", "p2035_high", "t0003"), + ("p2035_high", "t0002", "t0001", "t0001", "p2035_high", "t0001"), + ("p2035_high", "t0003", "t0002", "t0002", "p2035_high", "t0002"), ("p2035_low", "t0001", "t0003", "t0003", "p2035_low", "t0003"), ("p2035_low", "t0002", "t0001", "t0001", "p2035_low", "t0001"), ("p2035_low", "t0003", "t0002", "t0002", "p2035_low", "t0002"), ("p2040", "t0004", "t0003", "t0006", "p2035", "t0003"), ("p2040", "t0005", "t0004", "t0004", "p2040", "t0004"), ("p2040", "t0006", "t0005", "t0005", "p2040", "t0005"), + ("p2040_high", "t0004", "t0003", "t0006", "p2035_high", "t0003"), + ("p2040_high", "t0005", "t0004", "t0004", "p2040_high", "t0004"), + ("p2040_high", "t0006", "t0005", "t0005", "p2040_high", "t0005"), ("p2040_low", "t0004", "t0003", "t0006", "p2035_low", "t0003"), ("p2040_low", "t0005", "t0004", "t0004", "p2040_low", "t0004"), ("p2040_low", "t0006", "t0005", "t0005", "p2040_low", "t0005"), @@ -168,7 +191,8 @@ def test_stoch_period_bookkeeping_consistent(stoch_wf) -> None: continuation branch period has year rows.""" piu = pl.read_csv(stoch_wf / "solve_data" / "period_in_use_set.csv") assert piu["period"].to_list() == [ - "p2035", "p2035_low", "p2040", "p2040_low", + "p2035", "p2035_high", "p2035_low", + "p2040", "p2040_high", "p2040_low", ] pwh = pl.read_csv(stoch_wf / "solve_data" / "period_with_history.csv") pwh_periods = set(pwh["period"].to_list()) @@ -183,15 +207,16 @@ def test_stoch_period_bookkeeping_consistent(stoch_wf) -> None: def test_stoch_branch_weights(stoch_wf) -> None: - """§5.3-3: per-cohort normalization 0.25 / 0.75, constant along - each branch chain; pdt dense over the 12 (d, t) cells.""" + """§5.3-3, R3 re-grounded: forecasts-only per-cohort normalization — + phantom trunk → 0, forecasts {high:0.25, low:0.75}, constant along + each branch chain; pdt dense over the 18 (d, t) cells.""" pdw = pl.read_csv(stoch_wf / "solve_data" / "pd_branch_weight.csv") got = {r[0]: float(r[1]) for r in pdw.rows()} assert set(got) == set(EXPECTED_PD_BRANCH_WEIGHT) for p, w in EXPECTED_PD_BRANCH_WEIGHT.items(): assert got[p] == pytest.approx(w, abs=1e-12) pdtw = pl.read_csv(stoch_wf / "solve_data" / "pdt_branch_weight.csv") - assert pdtw.height == 12 + assert pdtw.height == 18 for r in pdtw.rows(named=True): assert float(r["value"]) == pytest.approx( EXPECTED_PD_BRANCH_WEIGHT[r["period"]], abs=1e-12 @@ -199,40 +224,28 @@ def test_stoch_branch_weights(stoch_wf) -> None: sbw = pl.read_csv(stoch_wf / "solve_data" / "solve_branch_weight.csv") got_input = {r[0]: float(r[1]) for r in sbw.rows()} assert got_input == { - "p2035": 1.0, "p2040": 1.0, "p2035_low": 3.0, "p2040_low": 3.0, + "p2035_high": 1.0, "p2040_high": 1.0, + "p2035_low": 3.0, "p2040_low": 3.0, } -def test_stoch_realized_gating_and_flows(stoch_wf) -> None: - """§5.3-4 (DEFECT PIN for the p2040 rows): realized_dispatch is the - 6 real-period rows; committed flow outputs carry no branch labels; - realized flows match the hand calc (wind 60/50, thermal 40/60 MW). - """ - rd = _csv_rows(stoch_wf, "realized_dispatch.csv") - assert rd == ( - [("p2035", t) for t in P1_STEPS] + [("p2040", t) for t in P2_STEPS] - ) - flows = _v_flow(stoch_wf) - assert sorted(set(flows["period"].to_list())) == ["p2035", "p2040"], ( - "branch periods leaked into the committed (realized-only) outputs" - ) - assert flows.height == 6 - for r in flows.rows(named=True): - wind_mw = r[WIND_COL] * WIND_EXISTING - thermal_mw = r[THERMAL_COL] * THERMAL_EXISTING - if r["period"] == "p2035": - assert wind_mw == pytest.approx(60.0, abs=1e-6) - assert thermal_mw == pytest.approx(40.0, abs=1e-6) - else: - assert wind_mw == pytest.approx(50.0, abs=1e-6) - assert thermal_mw == pytest.approx(60.0, abs=1e-6) +# NOTE (R3): ``test_stoch_realized_gating_and_flows`` was retired here. +# It read the weight-0 phantom trunk slice of the stochastic LP and +# asserted it as "realized" flows (wind 60/50, thermal 40/60) — but under +# the corrected forecasts-only model the real-named trunk carries no +# dispatch (it is a weight-0 phantom; the actual dispatch is on the +# forecast branches high/low). Its realized-actuals intent is covered +# end-to-end by the Model 2 realized-dispatch tests (W8 / W4b / W4c + +# test_realized_dispatch_coherence); the forecast-fan weighting and the +# branch dispatch reaching the LP are covered by the sibling tests +# test_stoch_branch_weights and test_horizon_branch_dispatch_reached_lp. def test_stoch_step_previous_continuation_linkage(stoch_wf) -> None: """§5.3-5 / §4 (DEFECT PIN for the p2040 rows): step_previous.csv links each continuation period to its own branch chain.""" sp = pl.read_csv(stoch_wf / "solve_data" / "step_previous.csv") - assert sp.height == 12 + assert sp.height == 18 first = { (r["period"], r["time"]): ( r["previous"], r["previous_within_timeset"], @@ -279,9 +292,10 @@ def test_stoch_csv_dtttdt_parity_guard(stoch_wf) -> None: for r in data.dtttdt.rows(named=True) } sp = pl.read_csv(stoch_wf / "solve_data" / "step_previous.csv") - assert sp.height == len(dtttdt) == 12 - first_steps = {("p2035", "t0001"), ("p2035_low", "t0001"), - ("p2040", "t0004"), ("p2040_low", "t0004")} + assert sp.height == len(dtttdt) == 18 + first_steps = {("p2035", "t0001"), ("p2035_high", "t0001"), + ("p2035_low", "t0001"), ("p2040", "t0004"), + ("p2040_high", "t0004"), ("p2040_low", "t0004")} for r in sp.rows(named=True): key = (r["period"], r["time"]) m = dtttdt[key] @@ -321,19 +335,23 @@ def test_horizon_objective_identical(horizon_wf) -> None: def test_horizon_branch_dispatch_reached_lp(horizon_wf) -> None: - """§5.3-8 (DEFECT PIN): the continuation branch profile data - actually constrained the solver — p2035_low / p2040_low rows with - wind 20/10 MW and thermal 80/100 MW.""" + """§5.3-8 (DEFECT PIN), R3 re-grounded: under the genuine {high, low} + fan every forecast branch's profile data constrained the solver — the + high fan (wind 60/50, thermal 40/60) and the low fan (wind 20/10, + thermal 80/100). The weight-0 phantom trunk p2035/p2040 carries no + dispatch (wind 0, thermal 0).""" flows = _v_flow(horizon_wf) - assert flows.height == 12 + assert flows.height == 18 periods = set(flows["period"].to_list()) - assert {"p2035_low", "p2040_low"} <= periods + assert {"p2035_high", "p2040_high", "p2035_low", "p2040_low"} <= periods for r in flows.rows(named=True): wind_mw = r[WIND_COL] * WIND_EXISTING thermal_mw = r[THERMAL_COL] * THERMAL_EXISTING expected = { - "p2035": (60.0, 40.0), - "p2040": (50.0, 60.0), + "p2035": (0.0, 0.0), + "p2040": (0.0, 0.0), + "p2035_high": (60.0, 40.0), + "p2040_high": (50.0, 60.0), "p2035_low": (20.0, 80.0), "p2040_low": (10.0, 100.0), }[r["period"]] diff --git a/tests/expected/2_day_stochastic_dispatch/costs__dt.csv b/tests/expected/2_day_stochastic_dispatch/costs__dt.csv index 26d4111d8..f9c894e02 100644 --- a/tests/expected/2_day_stochastic_dispatch/costs__dt.csv +++ b/tests/expected/2_day_stochastic_dispatch/costs__dt.csv @@ -1,52 +1,100 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,upward slack penalty,downward slack penalty,inertia slack penalty,non-synchronous slack penalty,upward reserve slack penalty,downward reserve slack penalty 2day_dispatch,period1,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0002,1070.7065,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0003,2273.7194,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0004,3629.1605,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0005,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0006,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0007,6516.4313,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0008,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0009,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0010,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0011,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0012,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0013,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0014,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0015,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0016,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0017,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0018,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0019,5837.5062,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0020,5024.6657,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0021,5054.7659,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0022,5459.3445,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0023,2991.234,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0024,5976.4511,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0025,4127.4361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0026,909.108,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0027,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0028,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0029,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0030,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0031,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0032,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0033,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0034,4981.2752,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0035,5284.8553,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0036,698.4998,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0037,2273.281,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0038,2240.5273,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0039,3002.2023,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0040,3413.3092,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0041,4524.7959,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0042,5728.2142,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0043,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0044,6016.029,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0045,6303.2569,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0046,4815.7709,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0047,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1,t0048,5733.3715,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0002,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0003,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0004,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0005,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0006,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0007,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0008,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0009,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0010,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0011,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0012,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0013,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0014,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0015,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0016,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0017,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0018,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0019,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0020,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0021,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0022,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0023,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0024,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0025,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0026,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0029,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0030,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0031,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0032,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0033,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0034,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0035,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0036,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0037,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0038,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0039,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0040,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0041,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0042,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0043,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0044,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0045,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0046,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0047,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1,t0048,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0002,655.6134,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0003,992.3634,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0004,800.5218,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0005,983.119,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0006,1145.1123,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0007,1629.1078,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0008,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0009,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0010,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0011,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0012,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0013,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0014,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0015,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0016,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0017,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0018,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0019,1459.3766,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0020,1256.1664,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0021,1263.6915,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0022,1364.8361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0023,747.8085,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0024,994.1128,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0025,531.859,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0026,1227.277,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0027,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0028,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0029,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0030,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0031,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0032,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0033,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0034,1245.3188,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0035,821.2138,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0036,674.625,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0037,568.3202,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0038,583.0525,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0039,1250.5506,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0040,931.1592,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0041,1131.199,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0042,1432.0536,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0043,1484.7124,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0044,1102.3277,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0045,1575.8142,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0046,1203.9427,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0047,1649.548,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_realized,t0048,1433.3429,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0002,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0003,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 @@ -95,99 +143,99 @@ solve,period,time,commodity_cost,commodity_sales,co2,other operational,starts,up 2day_dispatch,period1_upper,t0046,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0047,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_upper,t0048,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0001,11397.96,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0002,11412.763,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0003,9985.4235,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0004,10630.024,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0005,12064.307,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0006,18727.334,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0007,23211.456,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0008,19780.124,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0009,24202.611,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0010,16521.113,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0011,17470.748,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0012,21701.484,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0013,20849.08,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0014,17615.41,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0015,20773.031,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0016,20736.417,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0017,18958.044,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0018,21383.452,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0019,24689.749,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0020,23326.498,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0021,24814.242,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0022,26063.118,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0023,22792.058,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0024,26158.157,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0025,24632.694,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0026,22829.059,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0027,20897.915,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0028,20530.57,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0029,22264.03,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0030,21363.496,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0031,19671.878,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0032,25379.524,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0033,22309.022,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0034,20344.885,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0035,19374.571,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0036,20824.539,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0037,20441.351,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0038,25503.099,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0039,26855.908,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0040,31569.272,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0041,28284.202,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0042,20716.816,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0043,18303.705,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0044,20048.464,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0045,30984.873,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0046,23479.08,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0047,23281.499,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_lower,t0048,25250.41,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0001,2849.4901,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0002,2853.1907,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0003,2496.3559,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0004,2657.5059,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0005,3016.0769,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0006,4681.8334,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0007,5837.6137,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0008,6179.8625,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0009,6979.5562,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0010,3958.8178,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0011,4338.6697,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0012,5275.2697,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0013,5220.2847,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0014,4357.4987,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0015,4187.6232,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0016,4614.4794,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0017,4535.2979,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0018,5315.7692,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0019,6172.4372,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0020,5831.6246,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0021,6203.5606,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0022,6515.7794,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0023,5698.0145,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0024,6539.5392,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0025,6158.1735,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0026,6647.3317,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0027,6187.9941,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0028,4469.8826,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0029,5600.4109,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0030,5044.725,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0031,4783.7972,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0032,5823.8991,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0033,5253.333,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0034,5086.2213,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0035,4843.6427,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0036,5206.1348,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0037,5110.3377,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0038,6375.7748,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0039,6713.9771,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0040,7892.318,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0041,7071.0506,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0042,5179.2041,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0043,4575.9262,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0044,5012.1159,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0045,7746.2183,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0046,5869.7699,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0047,5820.3748,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_lower,t0048,6312.6025,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0002,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0003,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 2day_dispatch,period1_mid,t0004,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0005,1175.2947,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0006,466.0532,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0007,5083.1792,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0008,4004.8596,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0009,2198.3774,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0010,3616.6987,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0011,5120.7032,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0012,4019.7766,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0013,4187.0299,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0014,5629.8043,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0015,4582.8911,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0016,4253.3614,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0017,5308.6361,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0018,4166.0225,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0019,4570.5286,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0020,2821.7028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0021,5902.0367,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0022,4159.6752,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0023,2733.8759,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0024,5660.9074,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0025,4967.2001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0026,2949.7409,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0027,3164.1093,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0028,4623.6857,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0029,2090.6966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0030,3599.8826,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0031,2885.6392,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0032,5832.7929,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0033,3330.4303,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0034,5192.0903,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0035,2522.1423,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0036,4096.4611,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0037,5739.638,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0038,4841.9875,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0039,6514.0338,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0040,6233.4279,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0041,4641.0052,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0042,6270.397,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0043,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0044,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0045,6285.5537,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0046,6020.4584,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0047,6666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 -2day_dispatch,period1_mid,t0048,6518.7515,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0005,293.8237,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0006,116.5133,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0007,1283.826,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0008,1464.2767,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0009,897.9331,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0010,839.8769,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0011,1269.2943,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0012,948.6561,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0013,1049.763,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0014,1390.0684,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0015,768.6099,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0016,849.731,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0017,1250.5791,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0018,1030.2205,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0019,1142.6321,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0020,705.4257,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0021,1475.5092,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0022,1039.9188,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0023,683.469,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0024,1415.2268,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0025,1241.8,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0026,1089.9603,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0027,1152.3456,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0028,907.3864,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0029,535.5754,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0030,788.9148,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0031,671.0952,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0032,1262.83,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0033,711.1366,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0034,1298.0226,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0035,630.5356,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0036,1024.1153,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0037,1434.9095,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0038,1210.4969,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0039,1628.5084,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0040,1558.357,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0041,1160.2513,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0042,1567.5993,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0043,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0044,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0045,1571.3884,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0046,1505.1146,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0047,1666.6667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 +2day_dispatch,period1_mid,t0048,1629.6879,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0 diff --git a/tests/expected/2_day_stochastic_dispatch/summary_solve.csv b/tests/expected/2_day_stochastic_dispatch/summary_solve.csv index 61e168db2..92f33bb97 100644 --- a/tests/expected/2_day_stochastic_dispatch/summary_solve.csv +++ b/tests/expected/2_day_stochastic_dispatch/summary_solve.csv @@ -1,11 +1,11 @@ -"Diagnostic results from all solves. Output at (UTC): 2026-10-07 16:43:54.956138+00:00" +"Diagnostic results from all solves. Output at (UTC): 2026-10-08 11:32:21.167739+00:00" "Solve","Objective","Total cost from solver, includes all penalty costs" -2day_dispatch,67.0203687913 -"Total cost (calculated) full horizon (M CUR)",268.081475165,"Annualized operational, penalty and investment costs" -"Total cost (calculated) realized periods (M CUR)",268.081475165 -"Operational costs for realized periods (M CUR)",268.081475165 +2day_dispatch,66.9291187913 +"Total cost (calculated) full horizon (M CUR)",66.9291187913,"Annualized operational, penalty and investment costs" +"Total cost (calculated) realized periods (M CUR)",66.9291187913 +"Operational costs for realized periods (M CUR)",66.9291187913 "Investment costs for realized periods (M CUR)",0 "Retirement costs / salvage revenue for realized periods (M CUR)",0 "Fixed costs for pre-existing entities (M CUR)",0 @@ -13,16 +13,20 @@ "Fixed cost removal due to divested entities (M CUR)",0 "Penalty (slack) costs for realized periods (M CUR)",0 -Period,period1,period1_lower,period1_mid,period1_upper -"Time in use in years",0.00547945205479,0.00547945205479,0.00547945205479,0.00547945205479 -"Operational discount factor",1,1,1,1 -"Investment discount factor",1,1,1,1 +Period,period1,period1_lower,period1_mid,period1_realized,period1_upper +"Time in use in years",0.00547945205479,0.00547945205479,0.00547945205479,0.00547945205479,0.00547945205479 +"Operational discount factor",1,1,1,1,1 +"Investment discount factor",1,1,1,1,1 Emissions "CO2 [Mt]",0,"System-wide annualized CO2 emissions for realized periods" "Slack variables multiplied by timestep duration (creating or removing energy/matter, creating inertia, adding synchronous generation, decreasing capacity margin, creating reserve)" -Removed, downriver, period1, 14400 +Created, demand_node, period1, 1.44e+05 +Created, downriver, period1, 4800 +Created, hydro_reservoir, period1, 2100 Removed, downriver, period1_lower, 14700 Removed, downriver, period1_mid, 14700 +Removed, downriver, period1_realized, 14700 Removed, downriver, period1_upper, 14700 +Removed, hydro_reservoir, period1, 21300 diff --git a/tests/fixtures/build_stoch_realized_dispatch.py b/tests/fixtures/build_stoch_realized_dispatch.py index 7a53f37d6..a318604c9 100644 --- a/tests/fixtures/build_stoch_realized_dispatch.py +++ b/tests/fixtures/build_stoch_realized_dispatch.py @@ -46,8 +46,10 @@ 1. ``stoch_rd`` — GENUINELY stochastic, ``solve_mode=single_solve`` (fan-at-first). ``stochastic_invest_method`` left at the DEFAULT ``none`` (shared first-stage invest — one unambiguous ``v_invest``). - Branch weights SUM TO 1.0 per cohort: rlz 0.25 / high 0.375 / - low 0.375. The fan creates synthetic p2035_high / p2035_low + The fan is FORECASTS-ONLY: the realized (``rlz``) branch is a + WEIGHT-0 designation (dispatch owned by the separate realized solve); + the two forecasts carry all the mass and sum to 1 (high 0.5 / + low 0.5). The fan creates synthetic p2035_high / p2035_low periods that own LP variables; every branch carries the SAME (deterministic) realized operational data, so the shared first-stage invest covers the per-step residual 60 MW → ``v_invest[peaker] = 60``. @@ -161,13 +163,23 @@ def build() -> dict: ] # ---- init: model / solves / time structure ------------------------ - # Single-period stochastic fan declared at p2035/t0001; weights SUM - # TO 1.0 per cohort (rlz 0.25 / high 0.375 / low 0.375). + # Single-period stochastic fan declared at p2035/t0001. Under the + # corrected forecasts-only model the realized (``rlz``) branch is a + # WEIGHT-0 designation pulled OUT of the weighted fan (its dispatch is + # owned by the separate realized-dispatch solve); the two FORECASTS + # carry all the probability mass and sum to 1 ON THEIR OWN + # (high 0.5 / low 0.5). This matches the weights the engine actually + # applies: the phantom rule already zeroes the realized trunk and + # renormalises the forecasts among themselves, so declaring rlz=0 and + # high=low=0.5 is behaviour-preserving (the applied pd/pdt weights are + # identical to the old rlz 0.25 / high 0.375 / low 0.375 encoding — the + # forecast RATIO 1:1 and the zeroed realized trunk are unchanged) while + # self-documenting the corrected model. stochastic_branches = _map([ ("p2035", _nested([ - ("rlz", _nested([("t0001", _nested([("yes", 0.25)]))])), - ("high", _nested([("t0001", _nested([("no", 0.375)]))])), - ("low", _nested([("t0001", _nested([("no", 0.375)]))])), + ("rlz", _nested([("t0001", _nested([("yes", 0.0)]))])), + ("high", _nested([("t0001", _nested([("no", 0.5)]))])), + ("low", _nested([("t0001", _nested([("no", 0.5)]))])), ])), ]) # DETERMINISTIC realized operational data (base alternative), read by diff --git a/tests/fixtures/build_stoch_two_period.py b/tests/fixtures/build_stoch_two_period.py index 51f1ca252..1e7b81204 100644 --- a/tests/fixtures/build_stoch_two_period.py +++ b/tests/fixtures/build_stoch_two_period.py @@ -11,9 +11,11 @@ (p2035 = t0001-t0003, p2040 = t0004-t0006); a demand node ``city`` (100 MW then 110 MW), a free wind unit capped by a branch-varying profile (via ``group__unit`` so no node enters the stochastic group), -and a 50 EUR/MWh thermal backstop. Branches ``rlz`` (realized, -weight 1.0) and ``low`` (weight 3.0) are declared at p2035/t0001, so +and a 50 EUR/MWh thermal backstop. Forecast branches ``high`` +(weight 1.0) and ``low`` (weight 3.0) are declared at p2035/t0001, so p2040 is a CONTINUATION period — the surface the fan-out fix repairs. +Both branches are forecasts (``realized:no``); n_real = 0 — a genuine +``{high, low}`` fan with no realized designation. Hand calculation (design §5.2): stochastic objective 70 080 000, deterministic control 43 800 000. @@ -115,7 +117,7 @@ def build() -> dict: # ---- init: model / solve / time structure -------------------------- stochastic_branches = _map([ ("p2035", _nested([ - ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("high", _nested([("t0001", _nested([("no", 1.0)]))])), ("low", _nested([("t0001", _nested([("no", 3.0)]))])), ])), ]) @@ -123,7 +125,7 @@ def build() -> dict: [(t, 0.6) for t in P1_STEPS] + [(t, 0.5) for t in P2_STEPS] ) profile_stoch = _map([ - ("rlz", _nested([ + ("high", _nested([ ("t0001", _nested([(t, 0.6) for t in P1_STEPS])), ("t0004", _nested([(t, 0.5) for t in P2_STEPS])), ])), diff --git a/tests/fixtures/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py index 513879f21..d102a3390 100644 --- a/tests/fixtures/build_stoch_two_period_hedge.py +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -23,7 +23,7 @@ mid-horizon first stage is genuinely shared (§8.3). Effective demand seen by the investable ``base`` unit (design §8.1: p2035 = -40 shared; p2040 realized/high = 120, low = 60) is produced supply-side — +40 shared; p2040 high = 120, low = 60) is produced supply-side — NO stochastic NODE, so the LP stays separable / storage-free and the hand-calc is tractable (mirrors ``build_stoch_two_period_invest`` §8's mechanism note). A free existing ``wind`` unit with a branch-varying @@ -33,7 +33,7 @@ * wind existing 100 MW, cost 0; profile (share of 100 MW): - p2035 (pre-reveal, SHARED across branches): 0.8 → wind 80 → base residual 40 (= D1). - - p2040 realized (rlz): 0.0 → wind 0 → base residual 120 (= D2 high). + - p2040 high: 0.0 → wind 0 → base residual 120 (= D2 high). - p2040 low: 0.6 → wind 60 → base residual 60 (= D2 low). * unit ``base`` → ``elec`` — the investable peaker (virtual_unitsize 1, invest_total, discount_rate 0.05, lifetime 1 → annuity per MW-period @@ -51,7 +51,7 @@ distinct knob. Hand calculation (design §8.2), ``x = v_invest[base, p2035]``, -``r_s = v_invest[base, p2040_s]``, weights ``w_rlz = 0.25`` (D2 = 120), +``r_s = v_invest[base, p2040_s]``, weights ``w_high = 0.25`` (D2 = 120), ``w_low = 0.75`` (D2 = 60): RP = min_x 2100·x + Σ_s w_s·3150·(D2_s − x)^+ @@ -225,18 +225,18 @@ def build() -> dict: # Mid-horizon reveal (scenario ``hedge``): branches declared at p2040. stochastic_branches_rp = _map([ ("p2040", _nested([ - ("rlz", _nested([("t0004", _nested([("yes", 1.0)]))])), + ("high", _nested([("t0004", _nested([("no", 1.0)]))])), ("low", _nested([("t0004", _nested([("no", 3.0)]))])), ])), ]) # Wait-and-see (scenario ``hedge_ws``): branches declared at p2035. stochastic_branches_ws = _map([ ("p2035", _nested([ - ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("high", _nested([("t0001", _nested([("no", 1.0)]))])), ("low", _nested([("t0001", _nested([("no", 3.0)]))])), ])), ]) - # Deterministic profile (base alternative) — matches the rlz branch: + # Deterministic profile (base alternative) — matches the high branch: # 0.8 at p2035 (wind 80 → residual 40), 0.0 at p2040 (residual 120). profile_base = _map( [(t, 0.80) for t in P1_STEPS] + [(t, 0.0) for t in P2_STEPS] @@ -244,11 +244,11 @@ def build() -> dict: # Branch-varying wind profile (stoch alternative). Keyed # branch → period-first-step → per-step share of the 100 MW wind cap. # p2035 (t0001) is SHARED (0.8 in BOTH branches — pre-reveal data must - # be identical; under mid-horizon only the realized/trunk value is - # ever read). p2040 (t0004): rlz 0.0 (residual 120), low 0.6 - # (residual 60). + # be identical; under mid-horizon the reference forecast owns the real + # period names at n_real = 0, so its value binds the trunk). p2040 + # (t0004): high 0.0 (residual 120), low 0.6 (residual 60). profile_stoch = _map([ - ("rlz", _nested([ + ("high", _nested([ ("t0001", _nested([(t, 0.80) for t in P1_STEPS])), ("t0004", _nested([(t, 0.0) for t in P2_STEPS])), ])), diff --git a/tests/fixtures/build_stoch_two_period_invest.py b/tests/fixtures/build_stoch_two_period_invest.py index 46eb7da93..3c9f2a80c 100644 --- a/tests/fixtures/build_stoch_two_period_invest.py +++ b/tests/fixtures/build_stoch_two_period_invest.py @@ -3,8 +3,9 @@ (design ``specs/sliceD_recourse_invest_design.md`` §15.1, variant A). Extends the ``stoch_two_period`` shape (two 3-step periods p2035 / p2040, -branches ``rlz`` w=1.0 / ``low`` w=3.0 declared at p2035/t0001 so p2040 is -a CONTINUATION period) with an INVEST entity. The branch variation rides +forecast branches ``high`` w=1.0 / ``low`` w=3.0 declared at p2035/t0001 so +p2040 is a CONTINUATION period) with an INVEST entity. Both branches are +forecasts (``realized:no``); n_real = 0 — a genuine ``{high, low}`` fan. The branch variation rides a UNIT profile through ``group__unit`` (the base fixture's mechanism) — no NODE enters the stochastic group, so the node-side NA net-charge pinning (``model.py`` ``non_anticipativity_storage_use``, gated on ``group_node`` @@ -17,9 +18,9 @@ * node ``elec`` — balance, high penalty (VOLL), DETERMINISTIC demand 100 MW (p2035) / 130 MW (p2040). * unit ``wind`` → ``elec`` — existing 100 MW, cost 0, ``upper_limit`` - profile branch-varying via the stochastic group: rlz 0.40/0.40, - low 0.20/0.10 → wind supply rlz 40/40, low 20/10 → peaker residual - rlz 60/90, low 80/120 (the §15.1 demand table). + profile branch-varying via the stochastic group: high 0.40/0.40, + low 0.20/0.10 → wind supply high 40/40, low 20/10 → peaker residual + high 60/90, low 80/120 (the §15.1 demand table). * unit ``peaker`` → ``elec`` — variable cost 0, virtual_unitsize 1 MW, invest_method=invest_total, invest_cost 1 CUR/kW, discount_rate 0.05, lifetime 1 (→ annuity 1000·CRF(0.05,1) = 1050 exactly), @@ -31,7 +32,8 @@ the objective stays 196 875). Hand calculation (design §15.1): with the invest axis fanned, per-scenario -optimal invest {p2035:60, p2040:30, p2035_low:80, p2040_low:40}; annuity +optimal invest {p2035_high:60, p2040_high:30, p2035_low:80, p2040_low:40}; +annuity windows 2100 (branching) / 1050 (continuation); weighted objective 0.25·(60·2100 + 30·1050) + 0.75·(80·2100 + 40·1050) = 196 875. @@ -152,18 +154,18 @@ def build() -> dict: # ---- init: model / solve / time structure -------------------------- stochastic_branches = _map([ ("p2035", _nested([ - ("rlz", _nested([("t0001", _nested([("yes", 1.0)]))])), + ("high", _nested([("t0001", _nested([("no", 1.0)]))])), ("low", _nested([("t0001", _nested([("no", 3.0)]))])), ])), ]) - # Deterministic profile (base alternative) — matches the rlz branch. + # Deterministic profile (base alternative) — matches the high branch. profile_base = _map( [(t, 0.40) for t in P1_STEPS] + [(t, 0.40) for t in P2_STEPS] ) # Branch-varying wind profile (stoch alternative): wind supply - # rlz 40/40 MW, low 20/10 MW → peaker residual rlz 60/90, low 80/120. + # high 40/40 MW, low 20/10 MW → peaker residual high 60/90, low 80/120. profile_stoch = _map([ - ("rlz", _nested([ + ("high", _nested([ ("t0001", _nested([(t, 0.40) for t in P1_STEPS])), ("t0004", _nested([(t, 0.40) for t in P2_STEPS])), ])), diff --git a/tests/fixtures/build_w4c_divergent_dispatch.py b/tests/fixtures/build_w4c_divergent_dispatch.py index 75477cf2a..dca02d221 100644 --- a/tests/fixtures/build_w4c_divergent_dispatch.py +++ b/tests/fixtures/build_w4c_divergent_dispatch.py @@ -17,8 +17,10 @@ The three values are mutually distinct AND distinct from W4b's old 0.40, so the proof is unambiguous: the sibling reads 0.50 (not 0.40, not any -forecast, not the collapsed 0 fallback). Branch weights are unchanged -(rlz 0.25 / high 0.375 / low 0.375 — SUM 1.0 per cohort). +forecast, not the collapsed 0 fallback). Branch weights are inherited +from the W4b/W8 builder: the realized ``rlz`` branch is a WEIGHT-0 +designation and the two forecasts carry all the mass (high 0.5 / low 0.5, +SUM 1.0 on their own) — the corrected forecasts-only encoding. Hand calculation (sibling dispatch solve) ----------------------------------------- diff --git a/tests/fixtures/stoch_realized_dispatch.json b/tests/fixtures/stoch_realized_dispatch.json index a33b55b87..3b2a56e95 100644 --- a/tests/fixtures/stoch_realized_dispatch.json +++ b/tests/fixtures/stoch_realized_dispatch.json @@ -416,6 +416,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2296,7 +2310,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -3062,6 +3076,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -6254,7 +6279,7 @@ "stoch_rd", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", "map" ], "stoch" diff --git a/tests/fixtures/stoch_two_period.json b/tests/fixtures/stoch_two_period.json index 55fea1b31..0b29a886c 100644 --- a/tests/fixtures/stoch_two_period.json +++ b/tests/fixtures/stoch_two_period.json @@ -339,6 +339,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -388,6 +416,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -990,6 +1032,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1392,6 +1448,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2232,7 +2310,7 @@ "model", "version", [ - "Njk=", + "NzU=", "float" ], null, @@ -2294,6 +2372,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2833,6 +2933,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -2965,6 +3076,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -3075,6 +3197,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", @@ -3141,6 +3285,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -3251,6 +3406,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3891,6 +4068,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4539,6 +4734,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5175,6 +5388,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -5992,7 +6223,7 @@ "stoch_2p", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", "map" ], "stoch" @@ -6002,7 +6233,7 @@ "wprof", "profile", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuNl0sIFsidDAwMDIiLCAwLjZdLCBbInQwMDAzIiwgMC42XV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC41XSwgWyJ0MDAwNSIsIDAuNV0sIFsidDAwMDYiLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjJdLCBbInQwMDAyIiwgMC4yXSwgWyJ0MDAwMyIsIDAuMl1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMV0sIFsidDAwMDUiLCAwLjFdLCBbInQwMDA2IiwgMC4xXV19XV19XV19", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjZdLCBbInQwMDAyIiwgMC42XSwgWyJ0MDAwMyIsIDAuNl1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuNV0sIFsidDAwMDUiLCAwLjVdLCBbInQwMDA2IiwgMC41XV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC4yXSwgWyJ0MDAwMiIsIDAuMl0sIFsidDAwMDMiLCAwLjJdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjFdLCBbInQwMDA1IiwgMC4xXSwgWyJ0MDAwNiIsIDAuMV1dfV1dfV1dfQ==", "map" ], "stoch" diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json index d28dd152c..0c9452817 100644 --- a/tests/fixtures/stoch_two_period_hedge.json +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -416,6 +416,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2296,7 +2310,7 @@ "model", "version", [ - "NzQ=", + "NzU=", "float" ], null, @@ -3062,6 +3076,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -6497,7 +6522,7 @@ "wprof", "profile", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuOF0sIFsidDAwMDIiLCAwLjhdLCBbInQwMDAzIiwgMC44XV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC4wXSwgWyJ0MDAwNSIsIDAuMF0sIFsidDAwMDYiLCAwLjBdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjhdLCBbInQwMDAyIiwgMC44XSwgWyJ0MDAwMyIsIDAuOF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuNl0sIFsidDAwMDUiLCAwLjZdLCBbInQwMDA2IiwgMC42XV19XV19XV19", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjhdLCBbInQwMDAyIiwgMC44XSwgWyJ0MDAwMyIsIDAuOF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMF0sIFsidDAwMDUiLCAwLjBdLCBbInQwMDA2IiwgMC4wXV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC44XSwgWyJ0MDAwMiIsIDAuOF0sIFsidDAwMDMiLCAwLjhdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjZdLCBbInQwMDA1IiwgMC42XSwgWyJ0MDAwNiIsIDAuNl1dfV1dfV1dfQ==", "map" ], "stoch" @@ -6517,7 +6542,7 @@ "stoch_2p_hedge", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwNDAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwNDAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", "map" ], "rp" @@ -6527,7 +6552,7 @@ "stoch_2p_hedge", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", "map" ], "ws" diff --git a/tests/fixtures/stoch_two_period_invest.json b/tests/fixtures/stoch_two_period_invest.json index 7d071ea82..05da67c4f 100644 --- a/tests/fixtures/stoch_two_period_invest.json +++ b/tests/fixtures/stoch_two_period_invest.json @@ -339,6 +339,34 @@ "str" ] ], + [ + "construction_lead_time_methods", + [ + "ImltbWVkaWF0ZSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "ImNsb3Nlc3Rfc2VhbSI=", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "InByZXZpb3VzX3NlYW0i", + "str" + ] + ], + [ + "construction_lead_time_methods", + [ + "Im5leHRfc2VhbSI=", + "str" + ] + ], [ "conversion_methods", [ @@ -388,6 +416,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -990,6 +1032,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1213,20 +1269,6 @@ "InllcyI=", "str" ] - ], - [ - "stochastic_invest_methods", - [ - "Im5vbmUi", - "str" - ] - ], - [ - "stochastic_invest_methods", - [ - "InJlY291cnNlIg==", - "str" - ] ] ], "parameter_groups": [ @@ -1406,6 +1448,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "connection", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "connection", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "connection", "cumulative_max_capacity", @@ -2246,7 +2310,7 @@ "model", "version", [ - "Njk=", + "NzU=", "float" ], null, @@ -2308,6 +2372,28 @@ "A map of coefficients (Index: constraint name, value: coefficient) to represent the participation of the storage state in user-defined constraints. [state x coefficient] will be on the left side of the equation.", "constraint" ], + [ + "node", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "node", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "node", "cumulative_max_capacity", @@ -2847,6 +2933,17 @@ "[MW] Amount of reserve to be reserved. Constant or time.", "reserve" ], + [ + "reserve__upDown__group", + "reserve_duration", + [ + null, + null + ], + null, + "[h] Time span over which committed reserve power must be sustainable from storage. Energy obligation = reservation_MW \u00d7 reserve_duration, corrected by the reserve provider's efficiency. Only affects reserve provided by units/connections that draw on (up) or charge (down) a storage node. Absent or 0 means no storage-energy coupling. Constant.", + "reserve" + ], [ "reserve__upDown__group", "reserve_method", @@ -2979,6 +3076,17 @@ "Map for indicating how many years from the present solve are the periods included in the solve. Used for discounting. Index: period, value: years.", null ], + [ + "solve", + "dispatch_realization_method", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ], + "dispatch_realization_methods", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation \u2014 branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "solve_advanced" + ], [ "solve", "fix_storage_periods", @@ -3089,6 +3197,28 @@ "Numerical LP-autoscaler mode for this solve. 'full' (default) runs Layer 1 (detection) + Layer 2 (per-quantity-type column/row/cost rescaling) + Layer 3 (HiGHS user_*_scale). 'basic' drops Layer 2 (Layer 1 + Layer 3 only); 'solver_only' leaves scaling to the solver; 'off' disables all scaling including HiGHS-internal. Use 'basic' (or lower) for models whose Layer-2 rescaling mis-solves. The run-time --scaling flag / FLEXTOOL_SCALING env override this per-solve value.", "solve_advanced" ], + [ + "solve", + "shared_invest_periods", + [ + null, + null + ], + null, + "Array of periods over which investment is SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) or an empty Array means NO shared tie (byte-identical to prior behaviour: recourse stays per-branch, none stays shared). A period list -- e.g. the first period -- shares investment over those periods while later periods invest per-branch (recourse), the standard two-stage stochastic capacity-expansion mode. Requires stochastic_invest_method=recourse; a no-op under none (investment is already shared across all periods).", + "solve_advanced" + ], + [ + "solve", + "shared_operation_periods", + [ + null, + null + ], + null, + "Array of periods over which operations are SHARED (identical) across stochastic branches \u2014 the non-anticipative first-stage decision; later periods are per-branch recourse. Unset (default) uses the legacy window = all realized-dispatch timesteps union the fix_storage timesteps (byte-identical to prior behaviour). An explicitly empty Array frees operations from t0 (classic two-stage capacity expansion: one shared here-and-now investment plus operations that branch freely per scenario). A period list shares operations only over those periods' timesteps.", + "solve_advanced" + ], [ "solve", "solve_mode", @@ -3155,6 +3285,17 @@ "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", "solve_advanced" ], + [ + "solve", + "stochastic_invest_method", + [ + "Im5vbmUi", + "str" + ], + "stochastic_invest_methods", + "How stochastic branches participate in investment. 'none' (default) keeps investment realized-only: stochastic branches never enter the invest axis (v_invest stays on realized periods), byte-identical to prior behaviour. 'recourse' enables two-stage stochastic investment with recourse \u2014 shared (non-anticipative) investment before the branch point plus per-branch recourse investment at and after the branching period, with probability-weighted investment costs. Reserved for future expected-value and nested-tree modes.", + "solve_advanced" + ], [ "solve", "timeline_hole_multiplier", @@ -3265,6 +3406,28 @@ "A map of coefficients (index: constraint name, value: coefficient) that places v_invest[e, d] \u2014 new-build capacity decided in the current period \u2014 on the left side of the user-defined constraint. Not multiplied by unitsize.", "constraint" ], + [ + "unit", + "construction_lead_time", + [ + null, + null + ], + null, + "[years] Construction lead time: capacity ordered in a period becomes available this many years later, snapped to a period boundary per construction_lead_time_method (defaults to closest_seam when set; use immediate to keep capacity available from the decision period). Constant or period. Default 0 (instantaneous, as before).", + "investment" + ], + [ + "unit", + "construction_lead_time_method", + [ + null, + null + ], + "construction_lead_time_methods", + "How yr(order)+construction_lead_time snaps to a period boundary: immediate (no lag; capacity from the decision period; byte-identical to prior behaviour), closest_seam (nearest boundary, exact tie rounds up; the effective default when a lead time is set), previous_seam (round down), next_seam (round up). Unset resolves to immediate when no lead time, else closest_seam.", + "investment" + ], [ "unit", "conversion_method", @@ -3836,17 +3999,6 @@ null, "[per unit / minute] Maximum ramp up speed. Constant.", "tech_advanced" - ], - [ - "solve", - "stochastic_invest_method", - [ - "Im5vbmUi", - "str" - ], - "stochastic_invest_methods", - "How stochastic branches participate in investment (none/recourse).", - "solve_advanced" ] ], "parameter_types": [ @@ -3916,6 +4068,24 @@ "map", 1 ], + [ + "connection", + "construction_lead_time", + "float", + 0 + ], + [ + "connection", + "construction_lead_time", + "map", + 1 + ], + [ + "connection", + "construction_lead_time_method", + "str", + 0 + ], [ "connection", "cumulative_max_capacity", @@ -4564,6 +4734,24 @@ "map", 1 ], + [ + "node", + "construction_lead_time", + "float", + 0 + ], + [ + "node", + "construction_lead_time", + "map", + 1 + ], + [ + "node", + "construction_lead_time_method", + "str", + 0 + ], [ "node", "cumulative_max_capacity", @@ -5200,6 +5388,24 @@ "map", 1 ], + [ + "unit", + "construction_lead_time", + "float", + 0 + ], + [ + "unit", + "construction_lead_time", + "map", + 1 + ], + [ + "unit", + "construction_lead_time_method", + "str", + 0 + ], [ "unit", "conversion_method", @@ -6096,7 +6302,7 @@ "stoch_2p_inv", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", "map" ], "stoch" @@ -6106,7 +6312,7 @@ "wprof", "profile", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicmx6IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIDAuNF0sIFsidDAwMDIiLCAwLjRdLCBbInQwMDAzIiwgMC40XV19XSwgWyJ0MDAwNCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDA0IiwgMC40XSwgWyJ0MDAwNSIsIDAuNF0sIFsidDAwMDYiLCAwLjRdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjJdLCBbInQwMDAyIiwgMC4yXSwgWyJ0MDAwMyIsIDAuMl1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMV0sIFsidDAwMDUiLCAwLjFdLCBbInQwMDA2IiwgMC4xXV19XV19XV19", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuNF0sIFsidDAwMDUiLCAwLjRdLCBbInQwMDA2IiwgMC40XV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC4yXSwgWyJ0MDAwMiIsIDAuMl0sIFsidDAwMDMiLCAwLjJdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjFdLCBbInQwMDA1IiwgMC4xXSwgWyJ0MDAwNiIsIDAuMV1dfV1dfV1dfQ==", "map" ], "stoch" diff --git a/tests/fixtures/stochastics.json b/tests/fixtures/stochastics.json index df9189cda..670c38b1e 100644 --- a/tests/fixtures/stochastics.json +++ b/tests/fixtures/stochastics.json @@ -6504,7 +6504,7 @@ "2day_dispatch", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dXX1dLCBbInVwcGVyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93ZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XV19XV19", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsidXBwZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJsb3dlciIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dLCBbIm1pZCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dXX1dXX0=", "map" ], "2day" diff --git a/tests/fixtures/w4b_auto_dispatch.json b/tests/fixtures/w4b_auto_dispatch.json index ba1bf86ff..b8abe7a5d 100644 --- a/tests/fixtures/w4b_auto_dispatch.json +++ b/tests/fixtures/w4b_auto_dispatch.json @@ -6234,7 +6234,7 @@ "stoch_rd", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", "map" ], "stoch" diff --git a/tests/fixtures/w4b_reserve_dispatch.json b/tests/fixtures/w4b_reserve_dispatch.json index c9533aab3..f800ee447 100644 --- a/tests/fixtures/w4b_reserve_dispatch.json +++ b/tests/fixtures/w4b_reserve_dispatch.json @@ -6276,7 +6276,7 @@ "stoch_rd", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", "map" ], "stoch" diff --git a/tests/fixtures/w4c_divergent_dispatch.json b/tests/fixtures/w4c_divergent_dispatch.json index ea61750f6..356070e6a 100644 --- a/tests/fixtures/w4c_divergent_dispatch.json +++ b/tests/fixtures/w4c_divergent_dispatch.json @@ -6234,7 +6234,7 @@ "stoch_rd", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMjVdXX1dXX1dLCBbImhpZ2giLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMC4zNzVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjM3NV1dfV1dfV1dfV1dfQ==", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", "map" ], "stoch" diff --git a/tests/scenarios.yaml b/tests/scenarios.yaml index 2d31c8351..2069dd530 100644 --- a/tests/scenarios.yaml +++ b/tests/scenarios.yaml @@ -759,6 +759,16 @@ # NA change (R-NA): the trunk is de-anchored and the ties now couple only # the forecasts to each other (star to a reference forecast), a RELAXATION # of the old trunk==f1==f2==f3 star, so the new optimum is ≤ the old. - expected_objective: 268.081475165 + # + # Re-grounded 268.081475165 → 66.9291187913 (R2 + R3 + option A): the stray + # ``realized`` branch is retagged to a 4th forecast (genuine 4-forecast fan, + # n_real=0, each 0.25); the forecasts-only R2 weight rule pulls the + # reveal-period self-row (phantom period1) to weight 0; and the post-process + # now weights every per-(d, t) operational + slack cost term by + # pdt_branch_weight (option A), so ``Total cost (calculated)`` equals the + # probability-weighted LP objective exactly (the phantom's free slack ×0 = 0; + # the four forecasts ×0.25 sum to the expectation). This value IS the LP + # objective (summary_solve ``2day_dispatch`` row == this). + expected_objective: 66.9291187913 expected_objective_tolerance: 1.0e-4 time_budget_seconds: 30.0 diff --git a/tests/test_branch_weights.py b/tests/test_branch_weights.py index 8fc564943..0da496254 100644 --- a/tests/test_branch_weights.py +++ b/tests/test_branch_weights.py @@ -12,9 +12,20 @@ * Single-branch self-loop — the degenerate non-stochastic case used by every parity baseline today. Weight must be 1.0. - * Two equal-weight sibling branches sharing a parent period — the - canonical 2-branch stochastic case. Weights must be 0.5 / 0.5. - * Two unequal-weight branches (1:3) — weights must be 0.25 / 0.75. + * A genuine 2-FORECAST fan with equal weights — the trunk self-row is a + weight-0 phantom (forecasts-only, R2), the two forecasts normalise to + 0.5 / 0.5. + * A genuine 2-forecast fan with unequal weights (1:3) — phantom 0, the + two forecasts normalise proportionally to 0.25 / 0.75 (divide by the + SUM of forecast weights, not the count). + +Under the corrected forecasts-only cohort rule (R2), the stochastic fan +is forecasts-only (Σ = 1): a trunk period ``d`` that is both a self-row +``(d, d)`` AND the parent of ≥1 forecast is a PHANTOM carrying no +probability mass (weight 0); the forecast members renormalise among +themselves. The genuine 2-forecast fans keep the proportional-split +coverage that a single-forecast fan (which would collapse to {phantom:0, +forecast:1.0}) would lose. The writer reads the CSVs positionally with header skip, so a column order regression in any of the upstream writers would surface here as @@ -165,96 +176,112 @@ def test_single_branch_self_loop_weight_is_1(tmp_path: Path) -> None: def test_two_equal_weight_branches_normalise_to_half(tmp_path: Path) -> None: - """Two sibling branches with equal input weights → 0.5 / 0.5. - - The realised period (``p2020``) and an alternative branch - (``p2020_alt``) share parent ``p2020`` (FlexTool models this with - ``period__branch`` rows ``(p2020, p2020)`` and ``(p2020, - p2020_alt)``). Both branches share the same first timestep and the - same dt, so the denominator is the sum of both input weights. + """A genuine 2-forecast fan with equal input weights → 0.5 / 0.5. + + The trunk period ``p2020`` fans into TWO forecast branches + ``p2020_a`` / ``p2020_b`` (``period__branch`` rows ``(p2020, p2020)``, + ``(p2020, p2020_a)``, ``(p2020, p2020_b)``). Under the forecasts-only + rule the trunk self-row ``p2020`` is a weight-0 phantom; the two + forecasts share the same first timestep and dt, so the denominator is + the sum of the two forecast weights → each 0.5. (A single-forecast + fan would collapse to {p2020:0, forecast:1.0}, losing the split, so + this test uses two genuine forecasts.) """ solve_data = tmp_path / "solve_data" _write_solve_data( solve_data, period__branch=[ ("p2020", "p2020"), - ("p2020", "p2020_alt"), + ("p2020", "p2020_a"), + ("p2020", "p2020_b"), ], solve_branch_weight=[ - ("p2020", 1.0), - ("p2020_alt", 1.0), + ("p2020_a", 1.0), + ("p2020_b", 1.0), ], first_timesteps=[ ("p2020", "t01"), - ("p2020_alt", "t01"), + ("p2020_a", "t01"), + ("p2020_b", "t01"), ], steps_in_use=[ ("p2020", "t01", 1.0), ("p2020", "t02", 1.0), - ("p2020_alt", "t01", 1.0), - ("p2020_alt", "t02", 1.0), + ("p2020_a", "t01", 1.0), + ("p2020_a", "t02", 1.0), + ("p2020_b", "t01", 1.0), + ("p2020_b", "t02", 1.0), ], - period_in_use=["p2020", "p2020_alt"], + period_in_use=["p2020", "p2020_a", "p2020_b"], ) provider = _run_branch_weights(tmp_path, solve_data) pd_w = _read_pd_weights(provider) assert pd_w == { - "p2020": pytest.approx(0.5), - "p2020_alt": pytest.approx(0.5), + "p2020": pytest.approx(0.0), # weight-0 phantom trunk + "p2020_a": pytest.approx(0.5), + "p2020_b": pytest.approx(0.5), } pdt_w = _read_pdt_weights(provider) assert pdt_w == { - ("p2020", "t01"): pytest.approx(0.5), - ("p2020", "t02"): pytest.approx(0.5), - ("p2020_alt", "t01"): pytest.approx(0.5), - ("p2020_alt", "t02"): pytest.approx(0.5), + ("p2020", "t01"): pytest.approx(0.0), + ("p2020", "t02"): pytest.approx(0.0), + ("p2020_a", "t01"): pytest.approx(0.5), + ("p2020_a", "t02"): pytest.approx(0.5), + ("p2020_b", "t01"): pytest.approx(0.5), + ("p2020_b", "t02"): pytest.approx(0.5), } def test_two_unequal_weight_branches_normalise_proportionally( tmp_path: Path, ) -> None: - """Input weights 1 : 3 → 0.25 / 0.75 after normalisation. + """A genuine 2-forecast fan with input weights 1 : 3 → 0.25 / 0.75. - Verifies that the normaliser actually divides by the *sum* of input - weights and not, e.g., by the count of sibling branches (which would - produce 0.5 / 1.5). + Verifies that the normaliser divides by the *sum* of the forecast + weights and not, e.g., by the count of forecast branches (which would + produce 0.5 / 1.5). The trunk self-row ``p2020`` is a weight-0 + phantom (forecasts-only). """ solve_data = tmp_path / "solve_data" _write_solve_data( solve_data, period__branch=[ ("p2020", "p2020"), - ("p2020", "p2020_alt"), + ("p2020", "p2020_a"), + ("p2020", "p2020_b"), ], solve_branch_weight=[ - ("p2020", 1.0), - ("p2020_alt", 3.0), + ("p2020_a", 1.0), + ("p2020_b", 3.0), ], first_timesteps=[ ("p2020", "t01"), - ("p2020_alt", "t01"), + ("p2020_a", "t01"), + ("p2020_b", "t01"), ], steps_in_use=[ ("p2020", "t01", 1.0), - ("p2020_alt", "t01", 1.0), + ("p2020_a", "t01", 1.0), + ("p2020_b", "t01", 1.0), ], - period_in_use=["p2020", "p2020_alt"], + period_in_use=["p2020", "p2020_a", "p2020_b"], ) provider = _run_branch_weights(tmp_path, solve_data) pd_w = _read_pd_weights(provider) assert pd_w == { - "p2020": pytest.approx(0.25), - "p2020_alt": pytest.approx(0.75), + "p2020": pytest.approx(0.0), # weight-0 phantom trunk + "p2020_a": pytest.approx(0.25), + "p2020_b": pytest.approx(0.75), } pdt_w = _read_pdt_weights(provider) assert pdt_w == { - ("p2020", "t01"): pytest.approx(0.25), - ("p2020_alt", "t01"): pytest.approx(0.75), + ("p2020", "t01"): pytest.approx(0.0), + ("p2020_a", "t01"): pytest.approx(0.25), + ("p2020_b", "t01"): pytest.approx(0.75), } diff --git a/tests/test_cost_aggregation_semantics.py b/tests/test_cost_aggregation_semantics.py index 40b23d519..7980332fb 100644 --- a/tests/test_cost_aggregation_semantics.py +++ b/tests/test_cost_aggregation_semantics.py @@ -964,34 +964,38 @@ def test_stochastic_branch_weight( test_solver_config_dir: Path, workdir: Path, ) -> None: - """LP objective applies ``pdt_branch_weight`` to per-(d, t) cost terms - (mod objective ~line 2007); Python's - ``Total cost (calculated) full horizon`` does NOT apply it (calc_costs - sums per-step costs unweighted across periods — see L110, L133 of - ``flextool/process_outputs/calc_costs.py`` where branch_weight is - only mentioned in comments). - - For ``2_day_stochastic_dispatch`` (4 branches, each at uniform - ``pd_branch_weight = 0.25``) this gives a clean structural identity: - - LP_obj ≈ python_total_calc × avg(pd_branch_weight) - - or equivalently ``python_total_calc / LP_obj ≈ 1 / avg_weight = 4``. - - Regressions caught: - - * LP-side regression (mod stops applying ``pdt_branch_weight`` — - e.g. a future Python migration of the objective drops it): LP_obj - jumps to ≈ python_total_calc, ratio falls to 1.0. - * Python-side regression (someone fixes the calc_costs TODO and - multiplies cost terms by ``pdt_branch_weight``): python_total_calc - drops to ≈ LP_obj, ratio falls to 1.0. - - Either side moving without the other is a real semantic shift that - deserves an update of this test (and probably summary_solve labels). - The original ``test_stochastic_branch_weight`` xfail predicted the - Python-side fix is still pending; this passing test now stands as a - tripwire for either side moving. + """Both the LP objective AND the Python ``Total cost (calculated)`` + apply ``pdt_branch_weight`` to every per-(d, t) operational + slack cost + term, so the two MUST agree. + + The LP objective folds ``pdt_branch_weight[d, t]`` into ``op_factor`` / + ``startup_factor`` (``flextool/engine_polars/model.py``). Option (A) + makes the post-process do the same: ``calc_slacks`` / ``calc_costs`` + weight each per-(d, t) cost term by ``pdt_branch_weight`` (via + ``flextool/process_outputs/_branch_weight.py``), so + ``Total cost (calculated) full horizon`` IS the probability-weighted + expectation == the LP objective. + + For ``2_day_stochastic_dispatch`` (a genuine 4-forecast fan at + ``period1``, each forecast ``pd_branch_weight = 0.25``, plus the + weight-0 phantom self-row) the identity is exact: + + python_total_calc ≈ LP_obj + + This is the re-grounding the previous ``× avg_weight = 4`` identity + anticipated in its own docstring ("someone fixes the calc_costs TODO + and multiplies cost terms by pdt_branch_weight: python_total_calc drops + to ≈ LP_obj, ratio falls to 1.0 … deserves an update of this test"). + + Still a genuine tripwire — it fails if EITHER side stops applying the + weight: + + * LP-side regression (mod/model stops applying ``pdt_branch_weight``): + LP_obj jumps up (phantom slack re-enters the objective), diverging + from the weighted ``total_calc``. + * Python-side regression (option A reverts — the post-process stops + weighting): ``total_calc`` jumps up (phantom's free slack summed + unweighted ≈ 183 000 M), diverging from the weighted LP_obj. """ csv_dir = _run_scenario( "2_day_stochastic_dispatch", @@ -1001,9 +1005,11 @@ def test_stochastic_branch_weight( ) summary = _read_summary_solve(csv_dir) - # Pull pd_branch_weight from solve_data — no Python loader exists, so - # read the CSV directly. It's the writer's own output (Python migration - # batch 63) so the file is guaranteed to exist for any solve. + # Pull pd_branch_weight from solve_data — the writer's own output, so the + # file is guaranteed to exist for any solve. Assert the fixture is + # genuinely stochastic (a real multi-forecast fan with a weight-0 + # phantom), otherwise the identity below is vacuous (deterministic runs + # weight by 1.0 and trivially reconcile). weights: dict[str, float] = {} with open(workdir / "solve_data" / "pd_branch_weight.csv") as f: next(f) # header @@ -1011,27 +1017,33 @@ def test_stochastic_branch_weight( if len(row) >= 2 and row[0]: weights[row[0]] = float(row[1]) - assert len(weights) >= 2, ( - f"Fixture must produce ≥ 2 branches to exercise stochastic " - f"weighting; got {weights}" + forecast_weights = {b: w for b, w in weights.items() if w > 0.0} + phantom_weights = {b: w for b, w in weights.items() if w == 0.0} + assert len(forecast_weights) >= 2, ( + f"Fixture must produce ≥ 2 weighted forecast branches to exercise " + f"stochastic weighting; got {weights}" ) - avg_weight = sum(weights.values()) / len(weights) - assert avg_weight < 1.0, ( - f"Branch weights should normalise to < 1 per branch; got " - f"avg_weight={avg_weight} from {weights}" + assert phantom_weights, ( + f"Fan-at-first fixture must carry a weight-0 phantom self-row " + f"(forecasts-only R2); got {weights}" + ) + assert all(w < 1.0 for w in forecast_weights.values()), ( + f"Forecast weights should normalise to < 1 per branch; got " + f"{forecast_weights}" ) - expected_lp = summary["total_calc"] * avg_weight - rel_err = abs(summary["objective"] - expected_lp) / max( - abs(expected_lp), 1e-9 + # Core invariant: both sides apply pdt_branch_weight, so the Python + # calculated total equals the LP objective (the phantom's free slack + # × weight 0 = 0; the forecasts × their weights sum to the expectation). + rel_err = abs(summary["total_calc"] - summary["objective"]) / max( + abs(summary["objective"]), 1e-9 ) - assert rel_err <= 1e-3, ( - f"branch-weight identity broken: " + assert rel_err <= 1e-4, ( + f"branch-weight reconciliation broken: " f"LP_obj={summary['objective']:.6f}, " f"python_total_calc={summary['total_calc']:.6f}, " - f"avg_pd_branch_weight={avg_weight:.6f}, " - f"expected LP ≈ total_calc × avg = {expected_lp:.6f}, " f"rel_err={rel_err:.3e}. Either the LP stopped applying " - f"pdt_branch_weight or Python started applying it — see the " - f"docstring above for which side moved." + f"pdt_branch_weight (LP_obj jumps up) or the post-process option-A " + f"weighting reverted (total_calc jumps up with unweighted phantom " + f"slack) — see the docstring above for which side moved." ) diff --git a/tests/test_recourse_cost_reconciliation.py b/tests/test_recourse_cost_reconciliation.py index 61333b808..1223a1f10 100644 --- a/tests/test_recourse_cost_reconciliation.py +++ b/tests/test_recourse_cost_reconciliation.py @@ -1,24 +1,24 @@ -"""Slice D (I-a, §11.3 / §15.2 D8) — post-hoc invest-cost reconciliation. +"""Slice D (§11.3 / §15.2 D8), R3 re-grounded — post-hoc invest-cost +reconciliation under the forecasts-only model. Runs the ``recourse`` scenario of ``stoch_two_period_invest`` through the -FULL output pipeline (``write_outputs``) and asserts that the reported -investment cost reconciles to the LP objective's REALIZED invest -contribution — i.e. ``calc_costs`` probability-weights the committed -invest by ``pd_branch_weight`` under recourse (choice I-a), so the cost -breakdown sums back to the objective rather than over-reporting by -``1/w_realized``. - -Hand values (design §15.1): committed realized invest is 60 MW at p2035 -(annuity 2100) and 30 MW at p2040 (annuity 1050), realized branch weight -0.25: - - invest cost = 0.25·(60·2100 + 30·1050) = 39 375 (0.039375 M CUR) - -The UNWEIGHTED (I-b) recompute would report 60·2100 + 30·1050 = 157 500 -(0.1575 M) — 1/0.25 = 4× larger — so the assertion discriminates the -weighting. The full-expectation objective (0.196875 M) additionally -carries the non-realized low-branch contribution, which the committed -cost path correctly omits (§11.1). +FULL output pipeline (``write_outputs``) and asserts the reported +investment-cost breakdown against the LP objective. + +``stoch_two_period_invest`` is now a genuine 2-forecast fan (``high`` / +``low``, n_real = 0) revealing FAN-AT-FIRST (p2035). Under the corrected +forecasts-only model there is no realized member and no pre-reveal +here-and-now stage, so the real-named trunk periods p2035/p2040 are +weight-0 phantoms: the COMMITTED (realized-only) investment is 0, and the +cost breakdown's "Investment costs for realized periods" is therefore +0 M CUR — the committed-cost path does NOT fold the per-forecast +branch investment (which is wait-and-see, surfaced only under +``output_horizon``) into the realized total. + +The LP objective (0.196875 M) carries the full probability-weighted +per-forecast invest expectation +``0.25·(60·2100 + 30·1050) + 0.75·(80·2100 + 40·1050)`` — all of it on the +forecast branches, none of it committed to a realized period. """ from __future__ import annotations @@ -100,9 +100,12 @@ def test_recourse_invest_cost_reconciles_to_objective( invest_realized = _summary_row( csv_dir, "Investment costs for realized periods (M CUR)") - # I-a: the reported invest cost is the probability-weighted realized - # contribution 0.25·(60·2100 + 30·1050) = 0.039375 M — NOT the - # unweighted 0.1575 M (which would not reconcile to the objective). - assert invest_realized == pytest.approx(0.039375, rel=1e-6) - # The full-expectation objective carries the non-realized branches too. + # Forecasts-only fan-at-first: the committed (realized-only) invest is + # 0 (the real-named trunk is a weight-0 phantom — no here-and-now + # investment), so the "Investment costs for realized periods" total is + # 0 M. The per-forecast branch investment is wait-and-see and is NOT + # folded into the realized cost. + assert invest_realized == pytest.approx(0.0, abs=1e-9) + # The objective carries the full probability-weighted per-forecast + # invest expectation. assert objective == pytest.approx(0.196875, rel=1e-6) From c4653afdca0c702c0fa057342990b62656f6afdf Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Thu, 8 Oct 2026 18:44:08 +0300 Subject: [PATCH 79/87] refactor(stochastic): rename __realized lineage leaf to __anchor (R5-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recourse lineage/annuity leaf labelled "__realized" is a misnomer under the corrected model: it is the real-named calendar-anchor chain used to dedupe a shared pre-reveal capacity's annuity to one charge per calendar period (not per branch-copy) — it is NOT a realized scenario (realized is an optional designation, never a weighted fan member). Pure, behavior-preserving relabel: the leaf is an opaque LP-row key that is produced in exactly one place (_lineage_leaves) and compared nowhere (the annuity dedup tie-break keys off member==anchor, not the label), so renaming "__realized" -> "__anchor" changes no numeric result. The annuity over-count fix it guards is untouched (hedge RP=173250, annuity windows K1/K2=2100/3150). Docstrings/comments across _derived_branch.py, input.py, model.py re-grounded to describe the corrected model; the three test assertion literals updated. Nothing deleted: dd_same_scenario, dd_same_scenario_annuity, the dedup, and the per-path invest caps all stay (they remain load-bearing for mid-horizon / multi-stage recourse-invest, a kept capability). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_branch.py | 31 +++++++++++-------- flextool/engine_polars/input.py | 6 ++-- flextool/engine_polars/model.py | 2 +- .../test_derived_branch_lineage.py | 2 +- .../test_recourse_invest_gate.py | 6 ++-- .../test_recourse_invest_hedge.py | 8 ++--- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index f86b98c3e..4549db3fb 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -930,22 +930,26 @@ def _lineage_leaves( """Build the solve's scenario leaves (design §2.2). Returns ``(label, members)`` pairs (F4, Slice E design §6.2): the - trunk leaf labeled ``"__realized"`` and each branch leaf labeled its - time-branch id — so a caller that keys constraint rows off the leaf - (``d_leaf``) carries the authoritative label rather than re-deriving - and zipping it (a brittle two-independent-iteration alignment). - - Trunk leaf = all anchors (period order). One leaf per time-branch + real-named calendar-anchor leaf labeled ``"__anchor"`` (its members + are exactly the ``anchors`` list — the real-named, calendar-canonical + periods, under R-NAME owned by the reference forecast; this is NOT a + "realized" scenario, just the shared real-named chain) and each + branch leaf labeled its time-branch id — so a caller that keys + constraint rows off the leaf (``d_leaf``) carries the authoritative + label rather than re-deriving and zipping it (a brittle + two-independent-iteration alignment). + + Anchor leaf = all anchors (period order). One leaf per time-branch possessing at least one synthetic ``period_in_use`` member (F3 — - never iterate SBTB's own time-branches: SBTB also carries the - realized branch, whose synthetic-member set is empty). Per branch + never iterate SBTB's own time-branches; a forecast branch with an + empty synthetic-member set contributes no leaf). Per branch leaf, the per-period representative follows the F6 authority rule: the branch-local member is authoritative; a real anchor joins the leaf only when it is strictly pre-reveal AND the branch has no member for that period (Slice E Option B: a shared pre-reveal trunk period thus belongs to EVERY branch leaf's path). """ - leaves: list[tuple[str, list[str]]] = [("__realized", list(anchors))] + leaves: list[tuple[str, list[str]]] = [("__anchor", list(anchors))] branch_tbs = dict.fromkeys(tb_of[m] for m in synthetic) for br in branch_tbs: members = [m for m in synthetic if tb_of[m] == br] @@ -1154,7 +1158,8 @@ def d_leaf_lf( """``d_leaf`` — (d, leaf) scenario-leaf PARTITION of ``period_in_use``. Each in-use period maps to exactly ONE scenario leaf (design §7.2): - real-named anchors → the ``"__realized"`` leaf; synthetic fan + real-named anchors → the ``"__anchor"`` leaf (the real-named + calendar-anchor chain, not a "realized" scenario); synthetic fan members → their time-branch id (``tb_of[m]``). Because the continuation fan starts at the solve's first step (§K), leaves PARTITION the invest axis — no period is on two leaves — so the map @@ -1167,7 +1172,7 @@ def d_leaf_lf( once per scenario path, never cross-scenario double-counting. Deterministic / flag-off solves degenerate to a single - ``"__realized"`` leaf (``period__branch`` silent → every in-use + ``"__anchor"`` leaf (``period__branch`` silent → every in-use member is an anchor, mirroring :func:`_classify_lineage`'s ``pb_rows == []`` branch), so the total caps stay on the legacy single-row shape (byte-parity). ``leaf`` is a plain ``Utf8`` @@ -1183,7 +1188,7 @@ def d_leaf_lf( # Partition → RELATION (Slice E design §6.2). Under Option B a shared # pre-reveal trunk anchor belongs to EVERY scenario leaf's path, so it # must count into every leaf's per-path total cap — the strict - # partition (anchor → __realized only) would under-count the branch + # partition (anchor → __anchor only) would under-count the branch # leaves' totals. Rebuild from ``_lineage_leaves`` (the same relation # ``dd_same_scenario`` uses), carrying the authoritative leaf label # (F4). Byte-parity: deterministic / fan-at-first solves have no @@ -1762,7 +1767,7 @@ def apply_branch_cluster( workdir, source, active_solve, ctx=ctx, provider=provider) # Slice D per-path total caps (design §7.2): the (d, leaf) partition # that keeps entity/group total caps per-scenario-path. Single - # "__realized" leaf for deterministic solves (byte-parity). + # "__anchor" leaf for deterministic solves (byte-parity). flex_data.d_leaf = d_leaf_df( workdir, source, active_solve, ctx=ctx, provider=provider) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 72f8a0b87..ec134e2cc 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -940,11 +940,11 @@ class FlexData: dd_same_scenario: pl.DataFrame | None = None # (d, d_other) pd_non_anticipativity: pl.DataFrame | None = None # (d, b) # Slice D — per-scenario-leaf partition of period_in_use (design §7.2): - # (d, leaf) with real-named anchors → "__realized", synthetic fan - # members → their time-branch id. Drives the per-path total caps + # (d, leaf) with real-named calendar anchors → "__anchor", synthetic + # fan members → their time-branch id. Drives the per-path total caps # (maxInvest/maxDivest_entity_total_path, maxDivestGroup_entity_total_path) # so a total cap applies once per scenario path, never cross-scenario. - # Single "__realized" leaf for deterministic / flag-off solves → + # Single "__anchor" leaf for deterministic / flag-off solves → # caps stay on the legacy single-row shape (byte-parity). d_leaf: pl.DataFrame | None = None # (d, leaf) diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index c3832e45d..7a1a28944 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -132,7 +132,7 @@ def _emit_entity_total_cap( Sums ``var * unitsize`` over the invest axis's periods against a per-entity total cap. With a single scenario leaf — deterministic - or flag-off, where ``d_leaf`` degenerates to one ``"__realized"`` + or flag-off, where ``d_leaf`` degenerates to one ``"__anchor"`` leaf — this emits the legacy ``over=("d",)`` total under the legacy NAME, byte-identical to the pre-recourse constraint. With more than one leaf (recourse), it emits ``path_name`` indexed by diff --git a/tests/engine_polars/test_derived_branch_lineage.py b/tests/engine_polars/test_derived_branch_lineage.py index 03a3aa130..8905b798b 100644 --- a/tests/engine_polars/test_derived_branch_lineage.py +++ b/tests/engine_polars/test_derived_branch_lineage.py @@ -606,7 +606,7 @@ def test_annuity_branch_window_not_collapsed(tmp_path): """A genuine recourse window is NOT wrongly collapsed: for the branch anchor p2040_low the annuity frame EQUALS the capacity frame on its leaf. The real-named calendar siblings p2040 / p2045 live on the - ``__realized`` leaf, unreachable from p2040_low's branch leaf — so + ``__anchor`` leaf, unreachable from p2040_low's branch leaf — so there is nothing to dedup its own window against.""" cap, ann = _cap_and_annuity(tmp_path, pb=PB_ST3, piu=PIU_ST3, sbtb=SBTB_ST3) diff --git a/tests/engine_polars/test_recourse_invest_gate.py b/tests/engine_polars/test_recourse_invest_gate.py index 34126313d..667859436 100644 --- a/tests/engine_polars/test_recourse_invest_gate.py +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -219,7 +219,7 @@ def test_recourse_output_horizon_branch_rows(horizon_wf): @pytest.mark.solver def test_recourse_per_path_cap_two_leaf_rows(_resolved): """§15.1 / §7.2 (D6), R3 re-grounded: under recourse the entity total - cap fans into ONE row per scenario leaf (peaker × {__realized, high, + cap fans into ONE row per scenario leaf (peaker × {__anchor, high, low} of the genuine {high, low} fan) under the ``…_path`` name — the legacy single-row ``maxInvest_entity_total`` (which summed all periods, 210 > 150, and would spuriously bind) is gone. Each leaf row sums only @@ -238,9 +238,9 @@ def test_recourse_per_path_cap_two_leaf_rows(_resolved): assert "maxInvest_entity_total" not in names # legacy replaced recs = pb.cstrs_named("maxInvest_entity_total_path") over = recs[0].over - assert over.height == 3 # (peaker, {__realized, high, low}) + assert over.height == 3 # (peaker, {__anchor, high, low}) leaves = sorted(str(v) for v in over["leaf"].to_list()) - assert leaves == ["__realized", "high", "low"] + assert leaves == ["__anchor", "high", "low"] ents = {str(v) for v in over["p"].to_list()} assert ents == {"peaker"} diff --git a/tests/engine_polars/test_recourse_invest_hedge.py b/tests/engine_polars/test_recourse_invest_hedge.py index e845210b6..2245dbc5a 100644 --- a/tests/engine_polars/test_recourse_invest_hedge.py +++ b/tests/engine_polars/test_recourse_invest_hedge.py @@ -390,7 +390,7 @@ def test_lineage_shared_trunk_on_every_leaf(hedge_resolved) -> None: @pytest.mark.solver def test_d_leaf_is_a_relation_not_a_partition(hedge_resolved) -> None: """T-C / T-L (§6.2): d_leaf is a RELATION — the shared pre-reveal - anchor p2035 is on EVERY leaf (__realized, high, low) of the genuine + anchor p2035 is on EVERY leaf (__anchor, high, low) of the genuine {high, low} fan, so each scenario path's total cap counts it. A strict partition would put p2035 on one leaf only (under-counting the others). """ @@ -398,7 +398,7 @@ def test_d_leaf_is_a_relation_not_a_partition(hedge_resolved) -> None: dl = {(str(r["d"]), str(r["leaf"])) for r in data.d_leaf.iter_rows(named=True)} assert dl == { - ("p2035", "__realized"), ("p2040", "__realized"), + ("p2035", "__anchor"), ("p2040", "__anchor"), ("p2035", "high"), ("p2040_high", "high"), ("p2035", "low"), ("p2040_low", "low"), } @@ -421,7 +421,7 @@ def test_pd_non_anticipativity_empty_no_invest_na(hedge_resolved) -> None: @pytest.mark.solver def test_per_path_cap_two_leaf_rows(hedge_resolved) -> None: """T-C (§6.2 / §8.3): the entity total cap fans into ONE row per - scenario leaf (base × {__realized, high, low} of the genuine {high, + scenario leaf (base × {__anchor, high, low} of the genuine {high, low} fan) under the ``…_path`` name; the legacy single-row ``maxInvest_entity_total`` (which would sum all periods) is gone.""" _data, _sol, pb = hedge_resolved @@ -432,7 +432,7 @@ def test_per_path_cap_two_leaf_rows(hedge_resolved) -> None: over = recs[0].over assert over.height == 3 assert sorted(str(v) for v in over["leaf"].to_list()) == [ - "__realized", "high", "low", + "__anchor", "high", "low", ] assert {str(v) for v in over["p"].to_list()} == {"base"} From f65768f2958b2007a721d5d3742435254d5b857d Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 12 Oct 2026 09:50:28 +0300 Subject: [PATCH 80/87] fix(rolling): carry committed jump-end trunk storage state in handoff (H1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rolling-stochastic roll-to-roll storage carry (p_roll_continue_state) read the wrong value: it preferred the in-memory flex_data.realized_dispatch, which is loaded from the forecast-EXPANDED emit set (all branch periods × the full look-ahead window), so its lexically-last (d,t) was the discarded HORIZON-END of a FORECAST branch (e.g. period1_upper@t0096), not the committed JUMP-END of the realized TRUNK where the next roll continues. Fix (value-only, soft — no model.py / no variable pin): prefer the Provider anchor solve_data/realized_dispatch (trunk-only, committed jump-end, emitted by emit_realized_dispatch before the handoff is built) and fall back to the in-memory frame only when the Provider key is absent (unit-test synthesis). This restores the GMPL value source. Driven on 1_week_rolling_wind: all continuation rolls now carry the trunk jump-end (0.0) instead of 2500. Pre-existing (orphan-root) defect, latent on current fixtures because the soft carry is operationally inert under every wired storage-binding method (so solutions are byte-identical); it would distort a model whose storage economics bind across the roll boundary. A solution-level witness is therefore not constructible, so the regression guard is producer-level: test_rolling_handoff_carries_trunk_committed_jump_end_storage asserts the carried value is the trunk committed jump-end (fails on the old 2500, checking both the wrong-step and wrong-branch defects). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/input.py | 27 ++-- .../test_rolling_stochastic_na.py | 136 ++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index ec134e2cc..5324b183d 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5954,17 +5954,26 @@ def build_handoff_from_solution( # roll starts from a wrong initial v_state. roll_end_state_df = None nodes_state = _read_singles_csv(sd / "nodeState.csv", provider=provider) - # Prefer ``flex_data.realized_dispatch`` (in-memory). + # Carry the COMMITTED JUMP-END storage state of the realized TRUNK + # (restores GMPL value parity). Prefer the Provider anchor + # ``solve_data/realized_dispatch`` (trunk-only, committed jump-end — + # emitted by ``emit_realized_dispatch`` before the handoff is built) over + # the in-memory ``flex_data.realized_dispatch``: the latter is loaded from + # the forecast-EXPANDED ``dt_realize_dispatch_set.csv`` (all branch periods + # × the full look-ahead window), so its lexically-last (d,t) is the + # discarded HORIZON-END of a FORECAST branch, not the committed trunk + # jump-end — see NOTE above. Fall back to ``flex_data.realized_dispatch`` + # only when the Provider key is absent (unit-test synthesis, no real solve). last_pairs_df = None - if flex_data is not None and getattr(flex_data, "realized_dispatch", None) is not None: + rd_path = sd / "realized_dispatch.csv" + if _provider_has(provider, "solve_data/realized_dispatch", rd_path): + try: + last_pairs_df = _provider_read(provider, "solve_data/realized_dispatch", rd_path) + except pl.exceptions.NoDataError: + last_pairs_df = None + if (last_pairs_df is None and flex_data is not None + and getattr(flex_data, "realized_dispatch", None) is not None): last_pairs_df = flex_data.realized_dispatch - else: - rd_path = sd / "realized_dispatch.csv" - if _provider_has(provider, "solve_data/realized_dispatch", rd_path): - try: - last_pairs_df = _provider_read(provider, "solve_data/realized_dispatch", rd_path) - except pl.exceptions.NoDataError: - last_pairs_df = None if nodes_state and last_pairs_df is not None and "v_state" in sol._vars: # Schema: ``period, step``. Take the last (period, step) in # dispatch order — equivalent to ``_load_realized_period_time_last`` diff --git a/tests/engine_polars/test_rolling_stochastic_na.py b/tests/engine_polars/test_rolling_stochastic_na.py index 5d4958081..67277e581 100644 --- a/tests/engine_polars/test_rolling_stochastic_na.py +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -393,3 +393,139 @@ def test_rolling_na_ties_storage_across_branches(rolling_result): "forecast-to-forecast hub (trunk period1 absent) — the structural " "witness would be vacuous" ) + + +# rolling_solve_jump for this fixture (see module docstring). +_JUMP = 24 +_STORAGE_NODE = "hydro_reservoir" + + +def test_rolling_handoff_carries_trunk_committed_jump_end_storage(rolling_result): + """PRODUCER-LEVEL guard for the H1 rolling-storage handoff fix. + + The roll-to-roll storage carry (``SolveHandoff.roll_end_state``, consumed + next roll as ``p_roll_continue_state``) must carry the realized TRUNK's + ``v_state`` at the COMMITTED JUMP-END, NOT the discarded look-ahead + HORIZON-END of a forecast branch. The fix (``input.py`` ``roll_end_state`` + producer) sources the value from the Provider anchor + ``solve_data/realized_dispatch`` — trunk-only, committed jump-end — instead + of the forecast-EXPANDED in-memory ``flex_data.realized_dispatch`` (all + branch periods × the full look-ahead window), whose lexically-last ``(d,t)`` + is a forecast branch at the window horizon. + + WHY this is a PRODUCER-level guard (no solve consumes the value today): + the carried number is operationally INERT at the solution level. The soft + ``roll_continue`` balance term does not bind ``v_state[t_first]`` under any + wired ``storage_binding_method`` — the schema-default ``bind_forward_only`` + drops the first-step state-change term and plain ``bind_within_period`` is + unwired — so the full solution is byte-identical whatever the carried value + is. A solution-level witness is therefore impossible without the + owner-deprecated hard pin; this test guards the PRODUCED value directly. + (Cross-ref: scratchpad ``h1_design.md`` §3, ``handoff_carry_bug.md`` §2.) + + Two independent defects are witnessed, so a future regression of EITHER + surfaces here: + + * **wrong BRANCH** — the carry must come from the realized trunk + ``period1``, not a forecast copy (HEAD picked ``period1_upper``); + * **wrong STEP** — the carry must be the committed jump-end step, not the + discarded window-horizon step (HEAD picked the horizon). + + Discriminator on ``1_week_rolling_wind``: on continuation rolls 0-5 the + trunk committed jump-end ``v_state`` is ``0.0`` (an emptied reservoir), + while BOTH window-horizon candidates HEAD could pick — the trunk horizon + (``period1`` @ window end) and the forecast horizon (``period1_upper`` @ + window end) — are a non-zero ``0.5`` end-reference pin (× unitsize = 2500). + So ``carried == 0.0`` FAILS on HEAD (which carries 2500) and PASSES with + the fix, and the non-zero horizon candidates prove the ``0.0`` is the + jump-end of the trunk, not either horizon value. Roll 6 is the LAST roll: + its trunk realizes all the way to the window horizon (jump-end == horizon), + so the carry legitimately coincides with the horizon value and no next roll + consumes it — it is excluded from the strict ``0.0`` guard. + """ + steps = _steps(rolling_result) + names = list(steps) + assert len(names) == EXPECTED_ROLLS, ( + f"expected {EXPECTED_ROLLS} rolls, got {len(names)}") + + for i, name in enumerate(names): + s = steps[name] + res = s.handoff.roll_end_state + assert res is not None, f"roll {name!r}: roll_end_state unexpectedly None" + carried = {r["node"]: r["value"] for r in res.iter_rows(named=True)} + assert _STORAGE_NODE in carried, ( + f"roll {name!r}: {_STORAGE_NODE} absent from roll_end_state " + f"(got {sorted(carried)})") + carried_v = carried[_STORAGE_NODE] + + sol = s.solution + assert sol is not None, f"roll {name!r} retained no solution" + vs = sol.value("v_state") + cols = vs.columns + ncol = _pick(cols, "n", "node") + dcol = _pick(cols, "d", "period") + tcol = _pick(cols, "t", "time") + sub = (vs.filter(pl.col(ncol).cast(pl.Utf8) == _STORAGE_NODE) + .with_columns(pl.col(dcol).cast(pl.Utf8).alias("__d"), + pl.col(tcol).cast(pl.Utf8).alias("__t"))) + assert sub.height > 0, f"roll {name!r}: no {_STORAGE_NODE} v_state rows" + horizon_t = sub["__t"].max() + jump_end_t = f"t{_JUMP * (i + 1):04d}" + + def _vstate(d, t, _sub=sub): + r = _sub.filter((pl.col("__d") == d) & (pl.col("__t") == t)) + return r["value"][0] if r.height else None + + trunk_jump = _vstate("period1", jump_end_t) + trunk_horizon = _vstate("period1", horizon_t) + fcast_horizon = _vstate("period1_upper", horizon_t) + assert trunk_jump is not None, ( + f"roll {name!r}: no trunk v_state at jump-end (period1, {jump_end_t})") + assert fcast_horizon is not None, ( + f"roll {name!r}: no forecast v_state at horizon " + f"(period1_upper, {horizon_t})") + + if i < EXPECTED_ROLLS - 1: + # Continuation roll: the committed jump-end strictly precedes the + # discarded look-ahead window horizon (the step distinction is real). + assert jump_end_t < horizon_t, ( + f"roll {name!r}: jump-end {jump_end_t} not before horizon " + f"{horizon_t}") + + # (1) VALUE — the carry equals the realized TRUNK's committed + # jump-end v_state (0.0 here). FAILS ON HEAD, which carries the + # forecast horizon-end (0.5 × unitsize = 2500). + assert carried_v == pytest.approx(0.0, abs=1e-9), ( + f"roll {name!r}: carried storage {carried_v} is not the trunk " + f"committed jump-end (expected 0.0); HEAD's bug carries the " + f"forecast horizon-end (2500)") + assert trunk_jump == pytest.approx(0.0, abs=1e-9), ( + f"roll {name!r}: trunk jump-end v_state {trunk_jump} unexpected " + f"(fixture precondition: emptied reservoir at the jump-end)") + + # (2) STEP identity — the trunk WINDOW-HORIZON value is non-zero and + # differs from the jump-end, so carrying the jump-end (0.0) + # rather than the trunk horizon is a genuine distinction (guards + # the wrong-STEP defect independently). + assert trunk_horizon is not None, ( + f"roll {name!r}: no trunk v_state at horizon " + f"(period1, {horizon_t})") + assert abs(trunk_horizon - trunk_jump) > 1e-6, ( + f"roll {name!r}: trunk horizon {trunk_horizon} must differ from " + f"the carried jump-end {trunk_jump} for the step guard to bite") + + # (3) BRANCH identity — the FORECAST branch horizon value is + # non-zero and differs from the carried jump-end, so the carry + # is NOT a forecast-branch horizon value (guards the wrong-BRANCH + # defect independently). This is the exact (period1_upper, + # horizon) cell HEAD lexically selects. + assert abs(fcast_horizon - carried_v) > 1e-6, ( + f"roll {name!r}: carried {carried_v} coincides with the forecast " + f"horizon-end {fcast_horizon} — HEAD's wrong source") + else: + # Last roll: trunk realizes to the window horizon (jump-end == + # horizon), so the carry coincides with the horizon value and no + # next roll consumes it. Sanity only: geometry holds. + assert jump_end_t == horizon_t, ( + f"last roll {name!r}: jump-end {jump_end_t} != horizon " + f"{horizon_t} (trunk should realize to the window end)") From a1981b92444b709d122771b668c455631da79728 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 12 Oct 2026 21:02:04 +0300 Subject: [PATCH 81/87] fix(polars2): raise CommissioningLagError on unsupported i64->enum key cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The construction-lead-time commission_lf desync guard relied on polars <2.0 nulling an i64->Enum key cast under strict=False, then detecting the zero surviving lag rows and raising CommissioningLagError. polars >=2.0 treats i64->enum as an unsupported-type cast and raises ComputeError outright (regardless of strict), so CI (polars 2.0) surfaced the raw polars error instead of the dedicated guard error (and risked the downstream `except Exception` hiding it). Catch the polars cast error in the survivor-check and raise the same CommissioningLagError — the non-empty pre-cast overlap with an unmaterializable typed join IS the dtype desync the guard exists to catch. Byte-parity on polars 1.40 (the null-survivors path is unchanged); fixes the sole polars-2.0 failure in the 4.0.5 merge CI (test_construction_lead_time_guard). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_walks.py | 32 ++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/flextool/engine_polars/_derived_walks.py b/flextool/engine_polars/_derived_walks.py index a07318722..1e57c0d6f 100644 --- a/flextool/engine_polars/_derived_walks.py +++ b/flextool/engine_polars/_derived_walks.py @@ -388,17 +388,29 @@ def period_walk_iterator( .join(c_lf, on=["e", "d"], how="left") .with_columns(yr_avail=pl.coalesce("yr_c", "yr_d"))) if pre_overlap > 0: - post_survivors = (walk - .select(pl.col("yr_c").is_not_null().sum()) - .collect().item()) + from flextool.engine_polars._solve_state import ( + CommissioningLagError, + ) + _desync_msg = ( + "commission_lf join produced no surviving lag rows " + "despite a non-empty pre-cast key overlap — dtype or " + "calendar desync (see Slice H design §2.5 F3)") + try: + post_survivors = (walk + .select(pl.col("yr_c").is_not_null().sum()) + .collect().item()) + except (pl.exceptions.ComputeError, + pl.exceptions.InvalidOperationError) as exc: + # polars >=2.0 refuses an unsupported key-dtype cast (e.g. + # i64 -> Enum) outright instead of nulling it the way + # polars <2.0 did under ``strict=False``. Either way, a + # non-empty pre-cast overlap whose typed join cannot + # materialize IS the dtype desync this guard exists to + # catch — surface it as the dedicated error, not a raw + # polars one the downstream ``except Exception`` would hide. + raise CommissioningLagError(_desync_msg) from exc if post_survivors == 0: - from flextool.engine_polars._solve_state import ( - CommissioningLagError, - ) - raise CommissioningLagError( - "commission_lf join produced no surviving lag rows " - "despite a non-empty pre-cast key overlap — dtype or " - "calendar desync (see Slice H design §2.5 F3)") + raise CommissioningLagError(_desync_msg) else: walk = walk.with_columns(yr_avail=pl.col("yr_d")) From 9d3108364a9f38aa5ee45f39687ce66ca571103a Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Mon, 12 Oct 2026 23:32:25 +0300 Subject: [PATCH 82/87] docs(stochastic): re-ground user docs to the forecasts-only model (4.1.0 draft) Update the stochastics how-to + reference + results docs to match the re-grounded engine: the fan is forecasts-only (no realized fan member), "realized" is an optional at-most-one designation marking the actuals, non-anticipativity ties forecasts to each other (the tie is the realization), continuity is temporal carry-in, the reported cost is the probability-weighted expected cost, investment is single/shared by default with recourse as the per-branch opt-in, and output_horizon is a debug flag. Removes the old realized-as-anchor / realized-weight-1.0 / "choose which branches are realized" framing. Draft for owner review; schema param descriptions + example DB/images still pending (see review notes). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- docs/how_to.md | 58 ++++++++++++++++++++++++++++------------------- docs/reference.md | 4 ++-- docs/results.md | 2 +- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/how_to.md b/docs/how_to.md index e318e72a5..6a43dd638 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1129,11 +1129,15 @@ Both flags are documented in [Terminal / CLI](terminal_workflow.md). The FlexToo ## How to use stochastics (representing uncertainty) **(stochastics.sqlite)** -Stochastics are used to represent the uncertainty of the future in the decision making. The main idea is to represent the distribution of possible futures by discrete scenarios (branches). These are all connected to the same starting point with the realized branch. A non-anticipatory constraint forces the branches to share the decisions that affect multiple timesteps with the realized branch during the realized part of the horizon. These decisions are investments, storage usage and online status of units. The model then optimizes the system to get the minimize the total cost all the branches (weighted by their probability). Only the realized timeline of the realized branch, will be output. The realized branch can continue to the end of the horizon (and be also a forecast) or just to the end of the realized part as in the picture. +Stochastics are used to represent the uncertainty of the future in the decision making. The main idea is to represent the distribution of possible futures by a set of discrete, probability-weighted **forecast branches**. The branches fan out from a common branching point, and each carries a probability weight; the weights sum to one. The model minimises the **expected** (probability-weighted) cost across all the forecast branches. + +Because the decision maker does not yet know which future will occur, decisions that must be committed *before* the uncertainty is revealed cannot differ between the forecasts. A **non-anticipativity** constraint therefore forces the forecast branches to agree with each other on those here-and-now decisions over the committed part of the horizon — and that agreement *is* the (non-anticipative) realization of each such decision. The tied decision types are investments, storage usage and unit online (commitment) status; energy flows are always left free so each forecast dispatches them independently. (`min_uptime`, `min_downtime` and ramp limits are separate physics, always active within each branch's own trajectory — they are not part of the non-anticipativity tie.) + +There is **no "realized" branch among the weighted forecasts.** "Realized" is an *optional* designation — at most one branch per branching point — that marks which branch's data are treated as the actuals; it is used only to drive the committed / realized-dispatch results and is never a weighted member of the fan. By default a solve reports only its committed / realized results, not the individual forecast branches (use the `output_horizon` debug option, below, to expose the forecasts). ![Stochastic system](./img/concept/non-anticipatory.png) -For the stochastics to have an effect on the results, the system needs parameters that change between the stochastic branches. These could be e.g. wind power generation or fuel prices. The model will then have separate variables in every branch for all the decision the model can take (e.g. invesment, storage state, online, flow). As a consequence, the realization phase will also be dependent on the things that happen in the stochastic branches - weighted by the probablity given to each branch. +For the stochastics to have an effect on the results, the system needs parameters that change between the forecast branches. These could be e.g. wind power generation or fuel prices. The model then carries separate variables in every branch for all the decisions it can take (e.g. investment, storage state, online, flow), and minimises their probability-weighted (expected) cost. The committed decisions — tied across the branches as described above — therefore hedge against the whole distribution of forecasts rather than optimising for any single one. In this example, we show two ways to use stochastics: single solve and rolling horizon. They all share the same test system that includes: @@ -1158,38 +1162,39 @@ Notes about the storage options with stochastics: ### What the model ties across branches (dispatch vs. investment defaults) -Wherever the branches share the same information — the *realized* (committed) part of the horizon, before a forecast is revealed — the model **ties** some decisions across all branches to the realized branch and lets the rest **diverge**. What is tied depends on whether the solve invests. +Wherever the branches still share the same information — the committed part of the horizon, before the forecast is revealed — the model **ties** some decisions so that all forecast branches agree on them (the branches are tied *to each other*, not to any realized branch) and lets the rest **diverge**. What is tied depends on whether the solve invests. **Any stochastic solve (dispatch or investment) assumes, by default:** -- **Storage state, unit commitment (online status) and reserves are tied** across the branches over the solve's realized/committed periods — the branches must take the same storage and start-up decisions while they still share information. In a **single** solve whose realized periods span the whole horizon this pins them over the whole horizon (so only the flows differ between branches); in a **rolling** solve the tie covers only each roll's committed (jump) slice, and the branches operate freely in the forecast horizon beyond the reveal. -- **Energy flows are always free** — each scenario dispatches its flows independently. +- **Storage state, unit commitment (online status) and reserves are tied** across the forecast branches over the solve's committed periods — every branch must take the same storage and start-up decisions while they still share information, so these decisions are genuinely non-anticipative. In a **single** solve whose committed periods span the whole horizon this ties them over the whole horizon (so only the flows differ between branches); in a **rolling** solve the tie covers only each roll's committed (jump) slice, and the branches operate freely in the forecast horizon beyond the reveal. +- **Energy flows are always free** — each forecast dispatches its flows independently. +- **Continuity is ordinary temporal carry-in.** Only the model's very first timestep starts from the given history / initial state; every later timestep continues from the previous one. At a branching point each child branch continues from its parent branch's last step, so sibling branches start from the same state simply because they share a parent. **An investment solve additionally assumes, by default:** - **A single, shared here-and-now investment plan** for all branches (`stochastic_invest_method = none`) — you commit one set of investments regardless of which future occurs. -So a plain **dispatch** solve = commit the realized part (shared storage / online / reserve), forecast the rest, flows free. An **investment** solve = the same operational tie **plus** one shared investment plan. +So a plain **dispatch** solve = commit the committed part (shared storage / online / reserve), forecast the rest, flows free. An **investment** solve = the same operational tie **plus** one shared investment plan. **Overriding the defaults:** - *Investment* — set `stochastic_invest_method = recourse` for per-scenario (wait-and-see) investment, or declare a mid-horizon reveal for a genuine two-stage hedge (see *Stochastic investment* below). -- *Operations* — set the `solve` array parameter `shared_operation_periods` to change the operational tie window: an **empty array** frees operations from the first timestep (each scenario operates freely — combined with a single shared investment this is the classic two-stage capacity-expansion model), or a **list of periods** ties operations only over those periods. See *Stochastics over multiple periods* for the details. +- *Operations* — set the `solve` array parameter `shared_operation_periods` to change the operational tie window: an **empty array** frees operations from the first timestep (each forecast operates freely — combined with a single shared investment this is the classic two-stage capacity-expansion model), or a **list of periods** ties operations only over those periods. See *Stochastics over multiple periods* for the details. ### Single solve stochastics **(stochastics.sqlite scenario: 2_day_stochastic_dispatch)** -In this scenario, 24 hours are realized with an uncertain forecast for the next day. The timeset length is therefore 48 hours and the branching will happen at the timestep t0025. -To set up a stochastic solve, we need three things: The stochastic branch information, the stochastic timeseries and choosing which parts of the model use stochastic timeseries. +In this scenario a near-term operating window is committed with an uncertain forecast for the following day over a 48-hour timeset. +To set up a stochastic solve, we need three things: the stochastic branch information, the stochastic timeseries, and choosing which parts of the model use stochastic timeseries. -The information about the branches are set with the *solve* parameter `stochastic_branches`. This is a 4 dimensional map. (Edit -> choose map -> right click -> add columns). -The columns are: period, branch, analysis_time (starting time of the branch), realized (yes/no) and the value is the weight of the branch. Each starting time, including the first timestep, should have one and only one realized branch. This realized branch is used for the non-stochastic parts of the model with a stochastic timeseries. In this example we have three branches: mid, upper and lower representing the three wind forecasts. The weight will influence the relative weight of each branch in the objective function (model costs). The weights are normalized, so that the total weight is always one in each timestep. +The information about the branches is set with the *solve* parameter `stochastic_branches`. This is a 4-dimensional map. (Edit -> choose map -> right click -> add columns.) +The columns are: period, branch, analysis_time (starting time of the branch), realized (yes/no), and the value is the branch's **probability weight**. In this example we have forecast branches (e.g. mid, upper and lower) representing the wind forecasts. The weights set each branch's probability in the objective function (expected model cost) and are normalized so that the total weight is always one across the forecast branches at each start time. The *realized* flag is optional: at most one branch per start time may be flagged `yes`, and it only designates which branch's data are the actuals used for the committed / realized-dispatch results — a realized-flagged branch is **not** counted among the weighted forecasts. If no branch is flagged realized, the fan is pure forecasts and the "realization" of the committed decisions comes entirely from the non-anticipativity ties described above. -The stochastic wind timeseries are set with the same *profile* parameter `profile` but with a three dimensional map instead of the regular one dimesional map. The tool recognizes a stochastic timeseries from the dimensions of the data. The three dimensions are: branch, analysis_time and time. Here the realized part of the model (t0001-t0024) uses also the branch 'mid', which is one of the forecast branches as well. In the stochastic part, 'mid' is also set to be 'realized'. It is not actually realized (since the stochastic part is not realized), but it helps the model to choose which branch to use when there is no stochastic data available. +The stochastic wind timeseries are set with the same *profile* parameter `profile`, but with a three-dimensional map instead of the regular one-dimensional one. The tool recognizes a stochastic timeseries from the dimensions of the data. The three dimensions are: branch, analysis_time and time. The model reads the branch-specific series for each forecast branch; where a branch has no stochastic data available for part of the horizon, it falls back to the committed/reference series. -The parts of the model that will use stochastic data is chosen with a *group* parameter `include_stochastics`. The units, nodes and connections that are added to this group use stochastic timeseries connected to them (if available). Here we add the unit *wind_plant* to this group. There is a possiblity that to have two wind plants with the same profile, but use the stochastics on only one of them. The other would then use only the realized branch. +The parts of the model that will use stochastic data are chosen with a *group* parameter `include_stochastics`. The units, nodes and connections that are added to this group use the stochastic timeseries connected to them (if available). Here we add the unit *wind_plant* to this group. It is possible to have two wind plants with the same profile but use the stochastics on only one of them; the other would then use its single (non-stochastic) profile. -An option exists to output the stochastic horizons for debugging your system. This is done with the *model* parameter `output_horizon`. It will cause errors in the calculation of the cost in each solve (the costs of the unrealized branches will be included) so turn it off when you want the final results. +An option exists to output the stochastic (forecast-branch) horizons for debugging your system. This is done with the *model* parameter `output_horizon`. It will cause errors in the calculation of the cost in each solve (the costs of the unrealized forecast branches will be included, making the reported cost too high), so turn it off when you want the final results. If you change the weights of the stochastic branches, you should see the results change. @@ -1197,15 +1202,15 @@ If you change the weights of the stochastic branches, you should see the results ### Stochastics over multiple periods -When the solve's horizon spans periods beyond the branching period, the branches extend through all the remaining periods: the realized branch keeps the real period names (with its realized output windows), while every non-realized branch with a nonzero weight gets a synthetic `_` copy of each later period. The branch probability weights carry forward along each branch, so the weighting is constant along a branch's period chain and sums to one across the branches of each period. +When the solve's horizon spans periods beyond the branching period, the branches extend through all the remaining periods: the branch that owns the committed data — the designated realized branch, or (when none is designated) a reference forecast — keeps the real period names, while every other forecast branch with a nonzero weight gets a synthetic `_` copy of each later period. The branch probability weights carry forward along each branch, so the weighting is constant along a branch's period chain and sums to one across the forecast branches of each period. Notes for multi-period stochastic solves: - If `years_represented` is defined for the solve, it must cover **all** of the solve's periods. A configuration that defines it for only some periods fails with a configuration error (older versions silently ran on a truncated horizon instead). -- The realized branch's input weight is always treated as 1.0 along the whole realized chain — the sibling branches' weights are normalized against 1.0, not against a DB value on the realized row. -- In a single solve whose `realized_periods` span the whole horizon, the non-anticipatory constraints cover the whole horizon: the shared decision types (storage usage, online status, reserve) stay pinned to the realized branch through the later periods, and post-branching divergence shows up only in the flow variables. Use a rolling solve when the branches should be free to commit differently after the information is revealed — each roll then realizes only its jump slice and the non-anticipatory window ends at the reveal. -- To free the operational branches within a *single* solve (without moving to a rolling solve), set the `solve` array parameter `shared_operation_periods`. Left unset (the default) it keeps the behaviour above — the operational non-anticipativity window equals the realized-dispatch timesteps (plus any `fix_storage` timesteps), so operations stay pinned across the whole realized horizon. Set it to an **empty array** to free operations from the first timestep: the shared decision types then branch freely per scenario while the investment plan is still a single shared decision (`stochastic_invest_method = none`) — this is the classic two-stage capacity-expansion structure (one here-and-now investment, wait-and-see operations). Set it to a **list of periods** to pin operations only over those periods (e.g. a mid-horizon reveal: pin the pre-reveal periods, free the rest); the listed periods' timesteps are intersected with the solve's active timeline, and a curated list *replaces* (does not add to) the default realized-dispatch window. The dispatch output window is unaffected — `realized_periods` still governs which periods are reported. -- A `realized: yes` row placed at a later period's first timestep does not re-branch the model, but it selects which forecast branch's timeseries the realized chain uses from that period onwards. This can be used deliberately for per-period forecast-series selection; if you did not intend a series swap, keep the `realized: yes` rows only at the solve (or roll) start. +- The forecast branch weights are normalized to sum to one across the forecasts at each period; a realized designation adds no weight to the fan (it is not a weighted member). +- In a single solve whose committed periods span the whole horizon, the non-anticipativity constraints cover the whole horizon: the shared decision types (storage usage, online status, reserve) stay tied across the forecast branches through the later periods, and post-branching divergence shows up only in the flow variables. Use a rolling solve when the branches should be free to commit differently after the information is revealed — each roll then commits only its jump slice and the non-anticipativity window ends at the reveal. +- To free the operational branches within a *single* solve (without moving to a rolling solve), set the `solve` array parameter `shared_operation_periods`. Left unset (the default) it keeps the behaviour above — the operational non-anticipativity window equals the realized-dispatch timesteps (plus any `fix_storage` timesteps), so operations stay tied across the whole committed horizon. Set it to an **empty array** to free operations from the first timestep: the shared decision types then branch freely per forecast while the investment plan is still a single shared decision (`stochastic_invest_method = none`) — this is the classic two-stage capacity-expansion structure (one here-and-now investment, wait-and-see operations). Set it to a **list of periods** to tie operations only over those periods (e.g. a mid-horizon reveal: tie the pre-reveal periods, free the rest); the listed periods' timesteps are intersected with the solve's active timeline, and a curated list *replaces* (does not add to) the default realized-dispatch window. The dispatch output window is unaffected — `realized_periods` still governs which periods are reported. +- A `realized: yes` row placed at a later period's first timestep does not re-branch the model, but it selects which branch's timeseries the committed chain uses from that period onwards. This can be used deliberately for per-period series selection; if you did not intend a series swap, keep the `realized: yes` row only at the solve (or roll) start. ### Rolling horizon stochastics **(stochastics.sqlite scenario: 1_week_rolling_wind)** @@ -1214,9 +1219,16 @@ In this scenario we extend the previous timeline to one week and roll through it *solve*: `rolling_solve_jump` = 24 and `rolling_solve_horizon` = 96. -The stochastic data should be given so that it can serve the rolling structure: `rolling_solve_jump` dictates the duration of the realized part of each solve and the interval of forecast updates. So, for every 'analysis_time' there needs to be a time series in each stochastic branch that extends to the end of horizon (as defined by `rolling_solve_horizon`). The branches continue to the end of the horizon without branching again. The realized scenario should not typically be used as a forecast branch in a stochastic model. This is why its horizon is shorter than the others. +The stochastic data should be given so that it can serve the rolling structure: `rolling_solve_jump` dictates the duration of the committed (jump) part of each solve and the interval of forecast updates. So, for every `analysis_time` there needs to be a time series in each forecast branch that extends to the end of the horizon (as defined by `rolling_solve_horizon`). The branches continue to the end of the horizon without branching again. Each roll fans at its first step into the forecast branches; the non-anticipativity tie covers the committed jump slice, and the branches then diverge over the forecast tail. Only the committed jump is carried forward to the next roll as the realized state. + +For the *solve* parameter `stochastic_branches` this means an `analysis_time` at each roll start (here t0001, t0025, t0049, t0073, t0097, t0121 and t0145). + +A branch may optionally be designated realized to supply the committed/jump data each roll (useful when the near-term is known with certainty — deterministic near-term, uncertainty in the tail). If no branch is designated realized, the committed jump is instead fixed by the non-anticipativity tie across the forecasts. Either way, how the committed dispatch is produced is governed by the `solve` parameter `dispatch_realization_method`: + +- `within_solve` (default): the committed jump is realized inside the single rolling optimisation — no second solve per roll. +- `separate_dispatch`: realization is split into a dedicated downstream dispatch pass (committed investments/decisions fixed) that re-clears the revealed branch. This is the seed of a future multi-stage sequential-market structure. -For the *solve* parameter `stochastic_branches` this means analysis times (t0001, t0025, t0049, t0073, t0097, t0121 and t0145) +The selection is engine-managed rather than something you normally set per roll. (See the flags below — this area is still evolving for the 4.1 line; confirm the exact behaviour of `separate_dispatch` before relying on it.) Otherwise there is no difference to the previous example. @@ -1245,13 +1257,13 @@ The result is the standard two-stage stochastic capacity-expansion structure: ** #### Per-scenario (wait-and-see) investment -`stochastic_invest_method = recourse` lets each branch (the realized one included) invest on its own data at and after the branching period, with probability-weighted investment costs and per-scenario limits. When branches start at the solve's **first** step this is a *wait-and-see* analysis — each scenario has perfect foresight, so the objective is an EVPI lower bound, **not** a committed plan (it typically costs less than any single plan can). Useful for scenario screening and per-scenario capacity ranges; it is not a plan you can build. Non-realized branch investments are output only under `model.output_horizon` (a debug flag); committed results always report the realized scenario. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). +`stochastic_invest_method = recourse` lets each forecast branch invest on its own data at and after the branching period, with probability-weighted investment costs and per-branch limits. When branches start at the solve's **first** step this is a *wait-and-see* analysis — each forecast has perfect foresight, so the objective is an EVPI lower bound, **not** a committed plan (it typically costs less than any single plan can). Useful for scenario screening and per-branch capacity ranges; it is not a plan you can build. Forecast-branch (unrealized) investments are output only under `model.output_horizon` (a debug flag); committed results report the committed / realized region. `recourse` is incompatible with Benders decomposition (the solver rejects the combination). #### Hedged mid-horizon reveal (first periods deterministic for investment *and* operations) A third structure keeps the first periods **fully deterministic — a single shared trunk for both investment and operations** — and lets the branches fan out only from a later period. Set the branching period of the `stochastic_branches` map to that later period, with the branches' analysis time at that period's first step (the reveal must fall on a period boundary — investment is period-granular). -The pre-reveal periods stay a single real-named trunk with one shared `v_invest` *and* one shared operational schedule — non-anticipativity holds by construction (there is only one variable per pre-reveal period, so nothing to tie). The post-reveal periods fan per branch (second-stage recourse). Note how this differs from the standard mode above: here the **near-term operations are shared (committed) too**, not just the investment — appropriate when the first periods genuinely are deterministic (a committed near-term operating schedule) rather than a first period that should already be operated per scenario. The objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS); only the realized trunk is output. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. +The pre-reveal periods stay a single real-named trunk with one shared `v_invest` *and* one shared operational schedule — non-anticipativity holds by construction (there is only one variable per pre-reveal period, so nothing to tie). The post-reveal periods fan per branch (second-stage recourse). Note how this differs from the standard mode above: here the **near-term operations are shared (committed) too**, not just the investment — appropriate when the first periods genuinely are deterministic (a committed near-term operating schedule) rather than a first period that should already be operated per forecast. The objective sits strictly *between* the wait-and-see bound and the deterministic mean-value plan (positive EVPI and VSS); only the committed trunk is output. Mid-horizon reveal is a general stochastics capability — it works for dispatch-only hedged stochastics too, not only for `recourse` investment. ## How to use CPLEX as the solver diff --git a/docs/reference.md b/docs/reference.md index e8dad304c..4cdb88ff3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -79,8 +79,8 @@ Timesets pick one or more sections from the `timeline` to form a `timeset`. Each - Stochastic parameters: - - *stochastic_branches*: 4D-map to set up the stochastic branches, their weights and to choose which of them are realized. See 'How to use stochastics' for more information. - - *stochastic_invest_method*: `none` (default) or `recourse`. `none` makes a single shared investment plan across all branches. `recourse` gives per-scenario (wait-and-see) investment: every branch invests on its own data at and after the branching period, with probability-weighted investment costs and per-scenario investment limits. Non-realized branch investments are output only under `model.output_horizon`. Incompatible with Benders decomposition. See 'How to use stochastics' for the interpretation caveats. + - *stochastic_branches*: 4D-map defining the solve's **forecast branches** — the probability-weighted possible futures. Index columns: *period*, *branch*, *analysis_time* (the branch's start timestep), *realized* (yes/no), and the value is the branch's **probability weight**. The weights are normalized to sum to one across the forecast branches at each start time. The *realized* flag is an **optional designation** (at most one branch per start time) marking which branch's data are treated as the actuals for the committed / realized-dispatch results; a realized-flagged branch is **not** a weighted member of the forecast fan. See 'How to use stochastics' for more information. + - *stochastic_invest_method*: `none` (default) or `recourse`. `none` makes a single shared here-and-now investment plan across all forecast branches (operations still fan per branch). `recourse` gives per-branch (wait-and-see) investment: each forecast branch invests on its own data at and after the branching period, with probability-weighted investment costs and per-branch investment limits, and reports EVPI/VSS-style stochastic value. Forecast-branch (unrealized) investments are output only under `model.output_horizon` (a debug flag). Incompatible with Benders decomposition. See 'How to use stochastics' for the interpretation caveats. - For commercial solvers: diff --git a/docs/results.md b/docs/results.md index 2ec2ba94b..d2948625d 100644 --- a/docs/results.md +++ b/docs/results.md @@ -16,7 +16,7 @@ The CSV / Excel / plot files are **derived** from the parquets, so deleting or r All annualized numbers are scaled to **a full year of operation** by dividing through by `complete_period_share_of_year`. All values are returned in user units — auto-scaling (see [LP scaling pipeline](dev/scaling.md)) and time-block aggregation under flex-temporal decomposition are unwound before they reach the parquets, so users always read MW / MWh / [CUR] in the original problem space. -For stochastic models the parquets carry only the **realised branch** of each solve by default. Setting the `model` parameter `output_horizon = yes` adds the forecast-branch rows to the time-series tables (`*_t`, `*_dt`) so the unrealised futures are visible for debugging — note that cost aggregates in that mode mix realised + unrealised contributions and should not be used as final results (see also [How to use stochastics](how_to.md#how-to-use-stochastics-representing-uncertainty)). +For stochastic models the parquets carry only the **realised / committed** results of each solve by default — the here-and-now committed decisions plus, where a branch is designated realized, its dispatch — not the individual forecast branches. Setting the `model` parameter `output_horizon = yes` adds the forecast-branch (unrealised) rows to the time-series tables (`*_t`, `*_dt`) so the unrealised futures are visible for debugging — note that cost aggregates in that mode mix the committed results with the unrealised forecast contributions and should not be used as final results (see also [How to use stochastics](how_to.md#how-to-use-stochastics-representing-uncertainty)). - [Costs](#costs) - [Cost by entity](#cost-by-entity) From 828bdeaaa6fdfd57e6038422d28ac720cd742c35 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 13 Oct 2026 10:53:36 +0300 Subject: [PATCH 83/87] =?UTF-8?q?refactor(stochastic):=20quality=20cleanup?= =?UTF-8?q?=20=E2=80=94=20hoist,=20helpers,=20comment=20trim=20(P1+P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-parity quality pass over the stochastics changes (full suite 4145 passed, unchanged): - _stochastic.py: hoist the loop-invariant order/d_ref computation out of the per-period R-NAME guard loop; factor the duplicated cohort-validation raise blocks into _validate_cohort (same conditions/exceptions; the two validation messages lightly reworded for the shared helper, test-matched substrings preserved). - db_migration.py: extract _ensure_parameter_group for the solve_advanced / investment get-or-create duplicated across the v71-76 migrations. - Trim the heaviest inline design-spec/rejected-alternatives comment blocks (_stochastic W1, model.py storage_state_first, input.py NA-1, the NPV- preconditions docstring) to concise intent + the load-bearing rationale; removed dangling specs/ pointers (specs/ is gitignored). Net -133 lines. No computed-result change. (P1 "dead store" flex_data.dd_same_scenario left intact: it is read by tests, so not actually dead — deferred, not a free removal.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_derived_branch.py | 130 +++++-------- flextool/engine_polars/_stochastic.py | 220 ++++++++++------------ flextool/engine_polars/input.py | 52 ++--- flextool/engine_polars/model.py | 67 +++---- flextool/update_flextool/db_migration.py | 100 +++------- 5 files changed, 218 insertions(+), 351 deletions(-) diff --git a/flextool/engine_polars/_derived_branch.py b/flextool/engine_polars/_derived_branch.py index 4549db3fb..82a4d9c24 100644 --- a/flextool/engine_polars/_derived_branch.py +++ b/flextool/engine_polars/_derived_branch.py @@ -1481,77 +1481,47 @@ def check_recourse_npv_preconditions( provider: "object | None" = None, weight_tol: float = 1e-9, ) -> list[str]: - """Return violation messages for the three Slice A §2.2 NPV - obligations ((i) leaf-weight constancy, (ii) cohort sum-to-1, - (iii) byte-equal fan-member year rows). Empty list == all hold. - Deterministic message order. Raising wrapper: - :func:`assert_recourse_npv_preconditions`. - - FORECASTS-ONLY MODEL (R2/R4). The stochastic fan is forecasts-only - (Σ = 1): a trunk period that is both a self-row and the parent of ≥1 - forecast is a PHANTOM carrying weight 0; the forecast members - renormalise among themselves. The three obligations are read under - that model: - - (i) leaf-weight constancy — the probability-weight is constant - along each post-reveal forecast chain (a weight-0 phantom - anchor is a pre-reveal / cohort-of-one exclusion, not a - violation); - (ii) cohort sum-to-1 — the forecast members of a revealing cohort - sum to 1 (the weight-0 phantom contributes 0). Because the - producer (``pd_branch_weight_lf``) renormalises each - first-step forecast group to 1 and zeroes the phantom, a - WELL-FORMED cohort satisfies (ii) BY CONSTRUCTION — (ii) is - now largely a renormalisation tautology on the producer path. - It retains diagnostic power ONLY against a genuinely malformed - cohort whose forecast members straddle DIFFERENT first-step - normalization groups (each normalises to 1.0 independently, so - the cohort sums > 1) — see ``test_p3b_cohort_sum_violation``; - (iii) byte-equal fan-member year rows — unchanged. - - These obligations pin the arithmetic that makes a SET-shaped - (unweighted) lineage filter numerically correct for - trunk-crossing windows and realized-anchor windows (recourse plan - §9.4) — on the invest AND divest side (variant-agnostic: they - constrain inputs, not any one walker). Runtime wiring is the - Slice D activation site (once per solve, before the first - lineage-passing call); until then this checker is exercised by - tests only. - - SOUNDNESS SCOPE (Slice B design F4/§6.2): obligation (iii) is - checked against the emitted solve_data year frames (``p_years_d`` / - ``period_with_history`` / ``p_years_represented``). This checker - performs NO disk I/O — it reads those frames through the Provider - (``provider.get``), so the CALLER owns making the true emitted CSVs - available on the Provider before invoking it (tests seed the - Provider from the workdir; the Slice D activation site inherits the - same solve_data frames the rest of the cascade already consumes). - The check underwrites the lineage-filter arithmetic IF AND ONLY IF the - walker's year/factor source is those same CSVs (the Slice D - year-threading prerequisite, Slice B design §6.4). If Slice D - instead anchor-maps branch-period years/factors inside the - walker, a FOURTH obligation is required: per fan member, the - anchor-map's resolved (year, factor) equals the CSV rows this - checker verified — without it the checker re-decouples from the - walker, recreating the §6.4 defect. - - Obligation (i)'s chains are defined against SBTB (which carries - the realized branch's real-named members too, so the trunk chain - is covered). Slice E mid-horizon reveals re-scope constancy to the - POST-REVEAL segment of each chain: a strictly pre-reveal shared-trunk - anchor is a certain first-stage decision carrying weight 1.0, so the - body below (see the §5/§9.3 note by the obligation-(i) loop) excludes - such anchors from the leaf-weight-constancy chains — mixing their 1.0 - with a branch's scenario weight would be a false violation. For - fan-at-first-step / deterministic solves there are no pre-reveal - anchors, so the exclusion is a no-op and the check is byte-unchanged. - - Note: the excluded pre-reveal anchor's own weight-1.0 is STRUCTURAL — - it is a cohort-of-one in ``pd_branch_weight_lf`` (no fan siblings on - the shared trunk), so it is verified by NEITHER obligation (i) (it is - excluded from the chains) NOR obligation (ii) (a lone anchor forms no - fan cohort). This is an acceptable structural blind spot, not an - unchecked assumption. + """Return violation messages for the three recourse-NPV obligations; + empty list == all hold, deterministic order. Raising wrapper: + :func:`assert_recourse_npv_preconditions`. These obligations pin the + arithmetic that makes a SET-shaped (unweighted) lineage filter + numerically correct for trunk-crossing and realized-anchor windows, on + the invest AND divest side. Runtime wiring is the Slice D activation + site; until then this checker is exercised by tests only. + + The fan is forecasts-only (Σ = 1): a trunk period that is both a + self-row and the parent of ≥1 forecast is a PHANTOM carrying weight 0, + and the forecast members renormalise among themselves. Under that + model: + + (i) leaf-weight constancy — the probability-weight is constant along + each post-reveal forecast chain (a weight-0 phantom / pre-reveal + anchor is excluded, not a violation); + (ii) cohort sum-to-1 — the forecast members of a revealing cohort sum + to 1. The producer (``pd_branch_weight_lf``) renormalises each + first-step group to 1 and zeroes the phantom, so a well-formed + cohort satisfies (ii) BY CONSTRUCTION; (ii) retains diagnostic + power only against a malformed cohort straddling different + first-step groups (see ``test_p3b_cohort_sum_violation``); + (iii) byte-equal fan-member year rows. + + Obligation (iii) is checked against the emitted solve_data year frames + (``p_years_d`` / ``period_with_history`` / ``p_years_represented``). + The checker does NO disk I/O — it reads frames through the Provider, so + the CALLER must seed the true emitted CSVs onto the Provider first. The + check underwrites the filter arithmetic only if the walker's year/factor + source is those same CSVs (the Slice D year-threading prerequisite). + + Obligation (i)'s chains are defined against SBTB (which carries the + realized branch's real-named members, so the trunk chain is covered). + A strictly pre-reveal shared-trunk anchor is a certain first-stage + decision carrying weight 1.0, so the obligation-(i) loop below excludes + it from the constancy chains — mixing its 1.0 with a branch's scenario + weight would be a false violation. For fan-at-first-step / det solves + there are no pre-reveal anchors, so the exclusion is a no-op and the + check is byte-unchanged. That excluded anchor's weight-1.0 is a + cohort-of-one (verified by neither obligation) — an acceptable + structural blind spot, not an unchecked assumption. """ violations: list[str] = [] pb_rows, piu, tb_of = _lineage_inputs( @@ -1568,18 +1538,12 @@ def check_recourse_npv_preconditions( weights[str(d)] = float(v) # --- (i) per-leaf weight constancy ------------------------------------ - # Member chains = PIU representatives grouped by SBTB time-branch. - # - # Slice E mid-horizon re-scoping (design §5, §9.3; the note at the head - # of this function): under Option B a SHARED pre-reveal trunk anchor is - # a cohort-of-one first-stage decision carrying weight 1.0, while the - # post-reveal members of the same (realized) chain carry the scenario - # weight (e.g. 0.25). That is the CORRECT two-stage weighting — the - # first stage is certain, the recourse is probability-weighted — not a - # violation. Constancy is therefore required only over the POST-REVEAL - # segment of each chain; the shared pre-reveal anchors are excluded. - # For fan-at-first-step / deterministic solves there are no pre-reveal - # anchors, so the check is byte-unchanged. + # Member chains = PIU representatives grouped by SBTB time-branch. A + # shared pre-reveal trunk anchor is a cohort-of-one first-stage decision + # (weight 1.0); the post-reveal members carry the scenario weight — the + # correct two-stage weighting, not a violation. Constancy is required + # only over the POST-REVEAL segment, so pre-reveal anchors are excluded + # (a no-op, byte-unchanged, for fan-at-first-step / deterministic solves). anchors_i, pos_i, synthetic_i, anchor_of_i = _classify_lineage( pb_rows, piu, tb_of) pre_reveal_anchors: set[str] = set() diff --git a/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index 001f679f3..31cf4d885 100644 --- a/flextool/engine_polars/_stochastic.py +++ b/flextool/engine_polars/_stochastic.py @@ -366,6 +366,50 @@ def write_timeline_matching_map( # create_stochastic_periods — main entry point # ------------------------------------------------------------------ + def _validate_cohort(self, info, period, step, descr, realized_suffix=""): + """Enforce the fan invariants on one reveal cohort: at most one + ``realized:yes`` branch and at least one nonzero-weight forecast. + + ``step`` restricts the count to that start-step (the triggering + cohort); ``step=None`` counts every step of ``period`` (the + single-solve per-reveal sweep). ``descr`` / ``realized_suffix`` + carry the differing message fragments so the raised text stays + byte-identical to the two original call sites. Raises + FlexToolConfigError on violation. + """ + n_real = sum( + 1 + for r in info + if r[0] == period + and (step is None or r[2] == step) + and r[3] == "yes" + ) + n_forecast = sum( + 1 + for r in info + if r[0] == period + and (step is None or r[2] == step) + and r[3] != "yes" + and float(r[4]) != 0.0 + ) + if n_real > 1: + message = ( + f"{descr} must have at most one realized:yes branch per " + f"revealing cohort; found {n_real}. Check " + f"stochastic_branches{realized_suffix}." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + if n_forecast < 1: + message = ( + f"{descr} must contain at least one forecast (non-realized, " + f"nonzero-weight) branch; found {n_forecast}. A stochastic " + "fan is forecasts only — realized is an optional " + "designation. Check stochastic_branches." + ) + self.logger.error(message) + raise FlexToolConfigError(message) + def create_stochastic_periods( self, stochastic_branches: dict[str, Any], @@ -464,62 +508,31 @@ def create_stochastic_periods( branch_start_time_lists[solve] = None # ---------------------------------------------------------- - # Triggering-cohort validation (W1 — spec §A.1/§A.2/§F-step2/ - # §H.3; replaces the old "every row at its period's first step" - # + "one realized per period NAME" pair, which crashed rolling). + # Triggering-cohort validation (W1). A solve is triggered by + # exactly ONE reveal: the FIRST declared ``start_step`` reachable + # while walking THIS solve's (already period/roll-sliced) + # ``active_time_list`` in order — the same walk the trigger loop + # below does. ``info`` is read per COMPLETE solve, so a rolling + # parent's every roll sees ALL rolls' reveal rows; only the row + # that triggers THIS roll is validatable here, so we validate the + # triggering cohort only and tolerate the rest (each is validated + # in the roll where it triggers — a clean cross-roll pre-pass is + # infeasible, since a mid-horizon reveal is a PERIOD boundary, not + # a roll boundary, so it can't be told from a garbage step here). + # Checks: (i) the trigger step must be its period's FIRST step + # (reveals are period-granular boundaries); (ii) the cohort + # {rows sharing that (period, start_step)} must carry ≤1 + # realized:yes (the fan is forecasts-only; realized is optional) + # and ≥1 nonzero-weight forecast. # - # A solve is triggered by exactly ONE reveal: the FIRST declared - # ``start_step`` reachable while walking THIS solve's (already - # period/roll-sliced) ``active_time_list`` in order (period, - # then step) — the same walk the trigger loop below performs. - # ``info`` is read per COMPLETE solve, so for a rolling parent - # every roll sees ALL rolls' reveal rows; only the row that - # triggers THIS roll is validatable here (the rest are other - # rolls' triggers, each validated when it is its own roll's - # trigger). We therefore validate the triggering cohort only - # and tolerate the non-triggering rows: - # (i) the triggering ``start_step`` must be the FIRST step of - # its period in this solve's window — investment is - # period-granular, so a reveal is a PERIOD BOUNDARY; this - # admits BOTH Model-1 mid-horizon reveals and Model-2 - # fan-at-first (and does NOT re-introduce Option-B's - # "first period can't branch" guard — see Appendix). - # (ii) the triggering cohort {rows sharing that (period, - # start_step)} must carry AT MOST ONE realized:yes row - # (0 or 1 — the fan is forecasts only; realized is an - # optional designation) AND AT LEAST ONE forecast branch - # (realized:no with nonzero weight); 2+ realized or 0 - # forecasts → raise. - # When no reveal is reachable in this solve's window the solve - # stays a deterministic trunk (``branched`` remains False, so - # ``branch_start_time_lists[solve]`` stays None and the walk - # below copies every period as-is). That is correct for a - # rolling PRE-REVEAL roll; but for a SINGLE (non-rolling) solve - # a non-empty ``info`` with no reachable reveal means the - # branches would be silently ignored → misconfiguration, raise. - # Rolling vs single is detected structurally: a rolling roll's - # complete-solve carries ``solve_mode == "rolling_window"`` (set - # in RecursiveSolveBuilder), not by sniffing the ``_roll_N`` - # name suffix. - # - # Fix 2 (non-triggering malformed reveals): a clean cross-roll - # strict check is NOT feasible at this per-roll call site. Each - # roll sees only its own sliced window, and a declared step that - # is some OTHER roll's legitimate trigger is indistinguishable - # here from a jump-misaligned garbage step. A cross-roll - # pre-pass keyed on roll first-steps is no help either — it - # would false-reject a legitimate Model-1 mid-horizon reveal - # (which is a PERIOD boundary, not a roll boundary). So each - # reveal is validated in the roll where it triggers; this is the - # documented limitation for the rolling case. For a SINGLE - # (non-rolling) solve the WHOLE reveal picture is visible in the - # one window, so every declared reveal is validated strictly — - # each must sit at its period's first step, each branching - # period must carry at most one realized:yes (0 or 1) and at - # least one forecast branch (realized:no, nonzero weight) — - # restoring the pre-W1 per-row strictness (a mid-period or extra - # reveal in a single solve is a real authoring error, not another - # roll's reveal). This raises, matching the pre-W1 behaviour. + # No reachable reveal → deterministic trunk (``branched`` stays + # False): correct for a rolling PRE-REVEAL roll, but for a SINGLE + # (non-rolling) solve a non-empty ``info`` means the branches + # would be silently ignored → raise. Rolling vs single is + # detected structurally via ``solve_mode == "rolling_window"`` + # (set in RecursiveSolveBuilder), not the ``_roll_N`` name. For a + # single solve the whole reveal picture is visible in one window, + # so EVERY declared reveal is validated strictly (same checks). # ---------------------------------------------------------- is_rolling_sub_solve = ( getattr( @@ -568,41 +581,18 @@ def create_stochastic_periods( ) self.logger.error(message) raise FlexToolConfigError(message) - n_real = sum( - 1 - for r in info - if r[0] == trig_period and r[2] == trig_step and r[3] == "yes" - ) - n_forecast = sum( - 1 - for r in info - if r[0] == trig_period - and r[2] == trig_step - and r[3] != "yes" - and float(r[4]) != 0.0 + self._validate_cohort( + info, + trig_period, + trig_step, + f"The triggering stochastic branching cohort (period " + f"{trig_period!r}, start_step {trig_step!r})", + realized_suffix=( + " (the fan is forecasts only; realized is an optional " + "0-or-1 designation; start_step at the period's first " + "step)" + ), ) - if n_real > 1: - message = ( - "The triggering stochastic branching cohort (period " - f"{trig_period!r}, start_step {trig_step!r}) must have " - f"at most one realized:yes branch per revealing cohort; " - f"found {n_real}. Check stochastic_branches (the fan is " - "forecasts only; realized is an optional 0-or-1 " - "designation; start_step at the period's first step)." - ) - self.logger.error(message) - raise FlexToolConfigError(message) - if n_forecast < 1: - message = ( - "The triggering stochastic branching cohort (period " - f"{trig_period!r}, start_step {trig_step!r}) must " - "contain at least one forecast (non-realized, " - f"nonzero-weight) branch; found {n_forecast}. A " - "stochastic fan is forecasts only — realized is an " - "optional designation. Check stochastic_branches." - ) - self.logger.error(message) - raise FlexToolConfigError(message) if not is_rolling_sub_solve: # Single solve: validate EVERY declared reveal, not just @@ -626,36 +616,12 @@ def create_stochastic_periods( ) self.logger.error(message) raise FlexToolConfigError(message) - n_real_k = sum( - 1 for r in info - if r[0] == period_k and r[3] == "yes" + self._validate_cohort( + info, + period_k, + None, + f"Stochastic branching period {period_k!r}", ) - n_forecast_k = sum( - 1 for r in info - if r[0] == period_k - and r[3] != "yes" - and float(r[4]) != 0.0 - ) - if n_real_k > 1: - message = ( - f"Stochastic branching period {period_k!r} " - "must have at most one realized:yes branch per " - f"revealing cohort; found {n_real_k}. Check " - "stochastic_branches." - ) - self.logger.error(message) - raise FlexToolConfigError(message) - if n_forecast_k < 1: - message = ( - f"Stochastic branching period {period_k!r} " - "must contain at least one forecast " - "(non-realized, nonzero-weight) branch; found " - f"{n_forecast_k}. A stochastic fan is forecasts " - "only — realized is an optional designation. " - "Check stochastic_branches." - ) - self.logger.error(message) - raise FlexToolConfigError(message) # Walk periods — at the first branch trigger, fan out into # branches; subsequent periods continue the same branches. @@ -782,6 +748,17 @@ def create_stochastic_periods( # copies, so their real-named self-row stays a harmless weight-0 # phantom we leave alone (binding it would collide with the # recourse lineage chain grouping — see R-NAME design §2). + # + # ``order`` and ``d_ref_tok`` are loop-invariant across the period + # loop (active_time_list and active_branches are fixed here) — + # compute once. For a hypothetical multi-reveal solve d_ref scopes + # to the FIRST reveal cohort's forecasts; with a single reveal + # (every current fixture) active_branches IS exactly that cohort, + # so its lexical-min token is R-NA's reference forecast. + order = list(active_time_list.keys()) + d_ref_tok = ( + sorted(set(active_branches))[0] if active_branches else None + ) for period, active_time in active_time_list.items(): found = 0 # Before branching: row[0]==period, row[2]==first step, @@ -829,19 +806,12 @@ def create_stochastic_periods( if found == 0 and active_branches: bs = branch_start_time_lists[solve] reveal_period = bs[0] if bs is not None else None - order = list(active_time_list.keys()) if ( reveal_period is not None and period in order and reveal_period in order and order.index(period) < order.index(reveal_period) ): - # For a hypothetical multi-reveal solve, scope d_ref - # to the FIRST reveal cohort's forecasts; with a - # single reveal (every current fixture) active_branches - # IS exactly that cohort, so its lexical-min token is - # R-NA's reference forecast. - d_ref_tok = sorted(set(active_branches))[0] solve_branch__time_branch_lists[solve].append( (period, d_ref_tok) ) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 2aa932672..e78307c9a 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5082,39 +5082,27 @@ def _wire_inflow_split_for_synthetic_solve(): _loadflex_prof("apply_db_overrides:pass_synthetic_inflow") _timed("c.synth inflow", _wire_inflow_split_for_synthetic_solve) - # NA-1 — same class of gap as RESERVE-1 / SECTION-1 / MINLOAD-1 / - # INFLOW-1. The synthetic-solve early-return skips passes 3-10, so - # ``apply_derived_g``'s ``apply_branch_cluster`` — the sole producer - # of ``period_branch_full`` / ``dt_non_anticipativity`` — never runs - # on a rolling sub-solve (each ``_rolling_roll_N`` names a solve - # absent from Spine). Both frames keep their deliberately-None seeds - # (input.py:3844-3845), so the non-anticipativity gate - # (``model.py`` ``_add_non_anticipativity_constraints``) never fires - # and stochastic branches stay uncoupled across the rolling solve. - # The two ``*_df`` readers are solve-agnostic — they unroll the - # per-roll workdir CSVs (``period__branch.csv`` / - # ``dt_non_anticipativity_set.csv``) which the per-roll emit already - # writes correctly — and both return ``None`` (height==0) for a - # non-stochastic roll. We replicate ONLY ``apply_branch_cluster``'s - # two frame assignments verbatim (seed-preserving overlay, - # ``_derived_branch`` lines ~1655-1664) and deliberately do NOT call - # the full helper: it would overwrite the already-correctly-seeded - # ``period_in_use_set`` via an unsorted ``.unique()`` and reorder - # nested-invest set outputs. Safe for non-stochastic synthetic - # solves: with no d≠b fan rows ``period_branch_full`` yields only - # self-rows → ``db_pairs.height==0`` → the gate's inner d≠b guard - # skips the constraint even though ``dt_non_anticipativity`` is - # non-empty. See ``specs/model_bugs.md::NA-1``. + # NA-1: the synthetic-solve early-return skips passes 3-10, so + # ``apply_derived_g``'s ``apply_branch_cluster`` — the sole producer of + # ``period_branch_full`` / ``dt_non_anticipativity`` — never runs on a + # rolling sub-solve (each ``_rolling_roll_N`` names a solve absent + # from Spine). Without this wire both frames keep their None seeds + # (above), the non-anticipativity gate never fires, and stochastic + # branches stay uncoupled across the rolling solve. The two ``*_df`` + # readers are solve-agnostic — they unroll the per-roll workdir CSVs + # (``period__branch.csv`` / ``dt_non_anticipativity_set.csv``) already + # written correctly by the per-roll emit — returning None for a + # non-stochastic roll. We replicate ONLY ``apply_branch_cluster``'s two + # frame assignments (seed-preserving overlay) and deliberately do NOT + # call the full helper, which would overwrite the already-seeded + # ``period_in_use_set`` via an unsorted ``.unique()``. Safe for + # non-stochastic synthetic solves: no d≠b fan rows → ``db_pairs`` empty + # → the gate's d≠b guard skips the constraint. # - # NOTE (consumer surface): ``period_branch_full`` also feeds - # ``synthetic_branch_tokens`` in the invest handoff guards - # (``build_handoff_from_solution`` Slice C/D) and - # ``is_genuinely_stochastic`` (benders). Before this wire those were - # inert per roll (frame was ``None``); on a rolling-stochastic solve - # WITH investment or benders they now activate — intended parity with - # the single-solve path, but not yet exercised by a fixture (the - # stochastics fixture has no invest/benders). A rolling-stochastic- - # invest test is the follow-up slice. + # ``period_branch_full`` also feeds ``synthetic_branch_tokens`` (invest + # handoff guards) and ``is_genuinely_stochastic`` (benders); on a + # rolling-stochastic solve WITH investment or benders those now + # activate (parity with the single-solve path), not yet fixture-covered. def _wire_non_anticipativity_for_synthetic_solve(): from flextool.engine_polars._derived_branch import ( dt_non_anticipativity_df, diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index fe63e6b4b..920598cc9 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -368,53 +368,36 @@ def _add_non_anticipativity_constraints( rhs_terms = {}, ) - # 1b. non_anticipativity_storage_state_first — delivers §D.6 - # branch-point continuity by pinning ABSOLUTE ``v_state`` at the - # FIRST tied step of each anchor ``d`` (the reference forecast - # d_ref, NOT the realised trunk): + # 1b. non_anticipativity_storage_state_first — branch-point continuity by + # pinning ABSOLUTE ``v_state`` at the FIRST tied step of each anchor + # ``d`` (the reference forecast d_ref, NOT the realised trunk): # # v_state[n, d_ref, t_first] − v_state[n, b, t_first] == 0 # # The storage net-charge family above ties only the per-(n, t) net - # charge (the consecutive ``v_state`` delta), so across a contiguous - # tied jump it fixes every forecast's state SHAPE but leaves a free - # constant offset between the reference forecast and each other - # forecast copy. In single-solve each forecast is hard start-bound - # and reference-value end-bound, so the offset telescopes to zero on - # its own; in rolling the untied post-reveal tail breaks that - # telescoping and the forecast copies float by a constant (~0.06 in - # ``1_week_rolling_wind``). Pinning absolute ``v_state`` at the first - # tied step restores the anchor: combined with the per-step net-charge - # ties it closes the whole contiguous jump absolutely. + # charge (the consecutive ``v_state`` delta), fixing every forecast's + # state SHAPE across a contiguous tied jump but leaving a free constant + # offset between forecast copies. In single-solve each forecast is + # hard start-bound and reference-value end-bound so the offset + # telescopes to zero; in rolling the untied post-reveal tail breaks + # that and the copies float by a constant (~0.06 in + # ``1_week_rolling_wind``). Pinning the absolute state at the first + # tied step closes the jump absolutely. This is a forecast-to-forecast + # tie (re-pointed onto the forecast star via the shared db_pairs), + # never a tie to the realised period/trunk. It is OPTIMUM-CONDITIONAL, + # not a hard anchor: interior drift stays feasible when balance slack + # absorbs it or inflow differs across forecasts (the net-charge family + # controls neither), but is non-optimal for this fixture's + # deterministic-inflow zero-self-discharge reservoirs so the tie binds. + # ``v_online`` / ``v_reserve`` need no analog — their NA families + # already pin the absolute variable at EVERY tied step. # - # This tie is LOAD-BEARING and is KEPT (it re-points for free onto the - # forecast star via the shared re-grounded db_pairs / tie set — it is - # a forecast-to-forecast coupling, never a tie to the realised - # period/trunk). It is OPTIMUM-CONDITIONAL, not a hard structural - # anchor: it holds the forecasts equal to EACH OTHER at the branch - # point, but interior drift away from the shared level stays feasible - # whenever balance slack (``vq_state_up`` / ``vq_state_down``) absorbs - # it or the inflow differs across forecasts at the tied steps — the - # net-charge family controls neither. For the deterministic-inflow, - # zero-self-discharge reservoirs this fixture exercises, the drift is - # non-optimal and the tie binds. ``v_online`` / ``v_reserve`` need no - # analog: their own NA families already pin the ABSOLUTE variable at - # EVERY tied step, not just a net delta. - # - # NB: the hard roll-to-roll carry-in (all branches inheriting the - # previous roll's ACTUAL jump end-state, vs the current soft - # ``roll_continue`` seed) is a separate W8b concern. This slice ties - # the branches to each other at the branch point, closing the - # inter-branch offset; the shared level itself is still the soft - # carried value. - # - # PROACTIVE horizon-guard (byte-parity): emit the tie for an (n, d) - # ONLY when its tied timesteps are a STRICT SUBSET of that (n, d)'s - # ``v_state`` timeline, i.e. an untied post-reveal tail exists. When - # the tied set covers the full per-period horizon (single-solve - # dispatch) the node is already doubly anchored (hard start-binding + - # reference-value end), so the row is strictly redundant — suppress - # it so byte-parity-gated single solves receive ZERO new rows. + # Horizon-guard (byte-parity): emit the tie for an (n, d) ONLY when its + # tied timesteps are a STRICT SUBSET of that (n, d)'s ``v_state`` + # timeline (an untied tail exists). When the tied set covers the full + # horizon (single-solve dispatch) the node is already doubly anchored + # (hard start + reference end), so the row is redundant — suppress it + # so byte-parity-gated single solves get ZERO new rows. if (v_state is not None and d.groupStochastic is not None and db_pairs.height > 0): gn = d.group_node diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 909ec1641..6372827e4 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -33,6 +33,29 @@ def _commit_step(db, message): logging.info("Migration step idempotent (no changes): %s", message) +def _ensure_parameter_group(db, name, color, priority): + """Get-or-create a ``parameter_group`` so a new parameter can be + attached to it. + + Every parameter must belong to a parameter_group or it is dropped from + group-filtered tabular exports (v69 invariant, guarded by + tests/test_parameter_group_coverage.py). Real DBs always carry the + standard groups (created in v44, recoloured in v45), but a DB built + from scratch at a version above v44 — e.g. the minimal fixtures the + migration tests seed — never ran v44 and so has no parameter_groups. + Use the non-raising ``get_item`` (``db.item`` raises SpineDBAPIError + when absent); the create only fires on such group-less DBs and must + mirror v44/v45's canonical colour/priority for the group. + """ + if not db.get_item("parameter_group", name=name): + db.add_update_item( + "parameter_group", + name=name, + color=color, + priority=priority, + ) + + def migrate_database( database_path, up_to: int | None = None, @@ -3830,24 +3853,8 @@ def _migrate_v71_stochastic_invest_method(db) -> None: "nested-tree modes." ), ) - # Attach to the 'solve_advanced' parameter group, creating it if the - # DB lacks it. Every parameter must belong to a parameter_group so it - # is not dropped from group-filtered tabular exports (v69 invariant, - # guarded by tests/test_parameter_group_coverage.py), and solve_advanced - # is this param's canonical home in the schema. Real DBs always have it - # (created in v44, recoloured in v45), but a DB built from scratch at a - # version above v44 — e.g. the minimal fixtures the migration tests seed - # — never ran the v44 step and so carries no parameter_groups. Use the - # non-raising get_item (db.item raises SpineDBAPIError when absent) and - # get-or-create; the create only fires on such group-less DBs and mirrors - # v44's colour/priority for solve_advanced (post-v45 colour b56f6f). - if not db.get_item("parameter_group", name="solve_advanced"): - db.add_update_item( - "parameter_group", - name="solve_advanced", - color="b56f6f", - priority=87, - ) + # solve_advanced is this param's canonical home in the schema. + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) db.add_update_item( "parameter_definition", entity_class_name="solve", @@ -3902,16 +3909,7 @@ def _migrate_v72_non_anticipativity_periods(db) -> None: "ties operations only over those periods' timesteps." ), ) - # Attach to the 'solve_advanced' parameter group, creating it if the - # DB lacks it (mirrors v71; see that migration for the get-or-create - # rationale — minimal fixtures seeded above v44 carry no groups). - if not db.get_item("parameter_group", name="solve_advanced"): - db.add_update_item( - "parameter_group", - name="solve_advanced", - color="b56f6f", - priority=87, - ) + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) db.add_update_item( "parameter_definition", entity_class_name="solve", @@ -3974,17 +3972,7 @@ def _migrate_v73_non_anticipativity_invest_periods(db) -> None: "none (investment is already shared across all periods)." ), ) - # Attach to the 'solve_advanced' parameter group, creating it if the - # DB lacks it (mirrors v71/v72; see those migrations for the - # get-or-create rationale — minimal fixtures seeded above v44 carry - # no groups). - if not db.get_item("parameter_group", name="solve_advanced"): - db.add_update_item( - "parameter_group", - name="solve_advanced", - color="b56f6f", - priority=87, - ) + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) db.add_update_item( "parameter_definition", entity_class_name="solve", @@ -4052,18 +4040,8 @@ def _migrate_v74_construction_lead_time(db) -> None: "time is set), previous_seam (round down), next_seam (round up). " "Unset resolves to immediate when no lead time, else closest_seam." ) - # ``investment`` group already carries lifetime on these classes; real - # DBs have it (created pre-v44). Minimal fixtures seeded above that - # version carry no groups — get-or-create (mirrors v71/v73's - # solve_advanced rationale), using the schema colour/priority for - # ``investment`` (7fb095 / 20). - if not db.get_item("parameter_group", name="investment"): - db.add_update_item( - "parameter_group", - name="investment", - color="7fb095", - priority=20, - ) + # ``investment`` is construction_lead_time's canonical home. + _ensure_parameter_group(db, "investment", "7fb095", 20) for class_name in ("unit", "connection", "node"): db.add_update_item( "parameter_definition", @@ -4267,24 +4245,8 @@ def _migrate_v76_dispatch_realization_method(db) -> None: "implemented (lands in a later slice)." ), ) - # Attach to the 'solve_advanced' parameter group, creating it if the - # DB lacks it. Every parameter must belong to a parameter_group so it - # is not dropped from group-filtered tabular exports (v69 invariant, - # guarded by tests/test_parameter_group_coverage.py), and solve_advanced - # is this param's canonical home in the schema. Real DBs always have it - # (created in v44, recoloured in v45), but a DB built from scratch at a - # version above v44 — e.g. the minimal fixtures the migration tests seed - # — never ran the v44 step and so carries no parameter_groups. Use the - # non-raising get_item (db.item raises SpineDBAPIError when absent) and - # get-or-create; the create only fires on such group-less DBs and mirrors - # v44's colour/priority for solve_advanced (post-v45 colour b56f6f). - if not db.get_item("parameter_group", name="solve_advanced"): - db.add_update_item( - "parameter_group", - name="solve_advanced", - color="b56f6f", - priority=87, - ) + # solve_advanced is this param's canonical home in the schema. + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) db.add_update_item( "parameter_definition", entity_class_name="solve", From e168e332eafb7b8e33391e8028ca3f80a201e112 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Tue, 13 Oct 2026 11:02:24 +0300 Subject: [PATCH 84/87] docs(schema): correct stochastics param descriptions to the forecasts-only model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing DB-editor descriptions for two params were stale: - stochastic_branches: "only one of the branches should be realized" -> the fan is a probability-weighted forecasts-only fan; "realized" is an optional designation, at most one (0 or 1) per start_time, marking which branch's data are the actuals (not a weighted member). - dispatch_realization_method: dropped the "not yet implemented" clause (separate_dispatch landed via W4b/W4c auto-spawn); within_solve is the default normal path, separate_dispatch an available advanced option that spawns a separate realized-dispatch solve (automatic-selection defaults still being refined). Description-only (no structural change): corrected in spinedb_schema.json and set in the v76 migration (idempotent, so existing DBs get the text on upgrade through v76 — no version bump, DB_VERSION stays 76). v76 migration test extended to seed a below-v76 DB and migrate up. Master template + schema-sync verify green; no canonical/fixture JSON changes (descriptions live only in the schema). docs/how_to.md dispatch wording synced. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- docs/how_to.md | 6 +- flextool/schemas/spinedb_schema.json | 4 +- flextool/update_flextool/db_migration.py | 55 +++++++++--- .../test_v76_dispatch_realization_method.py | 86 +++++++++++++++++++ 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/docs/how_to.md b/docs/how_to.md index 6a43dd638..4ee3f8a9c 100644 --- a/docs/how_to.md +++ b/docs/how_to.md @@ -1225,10 +1225,10 @@ For the *solve* parameter `stochastic_branches` this means an `analysis_time` at A branch may optionally be designated realized to supply the committed/jump data each roll (useful when the near-term is known with certainty — deterministic near-term, uncertainty in the tail). If no branch is designated realized, the committed jump is instead fixed by the non-anticipativity tie across the forecasts. Either way, how the committed dispatch is produced is governed by the `solve` parameter `dispatch_realization_method`: -- `within_solve` (default): the committed jump is realized inside the single rolling optimisation — no second solve per roll. -- `separate_dispatch`: realization is split into a dedicated downstream dispatch pass (committed investments/decisions fixed) that re-clears the revealed branch. This is the seed of a future multi-stage sequential-market structure. +- `within_solve` (default): the normal path — the committed jump is realized inside the single rolling optimisation, no second solve per roll. +- `separate_dispatch`: an available advanced option that splits realization into a dedicated downstream dispatch pass (committed investments/decisions fixed) which automatically spawns a separate realized-dispatch solve to re-clear the revealed branch. This is the 2-stage seed of a future multi-stage sequential-market structure. -The selection is engine-managed rather than something you normally set per roll. (See the flags below — this area is still evolving for the 4.1 line; confirm the exact behaviour of `separate_dispatch` before relying on it.) +The selection is engine-managed rather than something you normally set per roll; `separate_dispatch`'s automatic-selection defaults are still being refined. Otherwise there is no difference to the previous example. diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index fa7319aa4..d7c0e8d24 100644 --- a/flextool/schemas/spinedb_schema.json +++ b/flextool/schemas/spinedb_schema.json @@ -2205,7 +2205,7 @@ "dispatch_realization_method", "within_solve", "dispatch_realization_methods", - "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) keeps realization inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' splits realization into a dedicated downstream dispatch stage (a second market-clearing pass over the revealed branch); this is the 2-stage seed of a future multi-stage sequential-market structure and is not yet implemented (lands in a later slice).", + "How the stochastic branches of a solve are realized into a committed dispatch. 'within_solve' (default) is the normal path: realization stays inside the single optimisation — branches and their realized periods are resolved within one solve, byte-identical to prior behaviour. 'separate_dispatch' is an available advanced option that splits realization into a dedicated downstream dispatch stage, automatically spawning a separate realized-dispatch solve (a second market-clearing pass over the revealed branch) — the 2-stage seed of a future multi-stage sequential-market structure. Its automatic-selection defaults are still being refined.", "solve_advanced" ], [ @@ -2349,7 +2349,7 @@ "stochastic_branches", null, null, - "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. Only one of the branches should be realized for each start_time", + "[4d-Map], Sets branches included in the solve. [Period, branch, start_time (time_step), realized (yes/no), weight (number)]. The branches form a probability-weighted forecast fan (forecasts-only). 'realized' is an optional designation, at most one (0 or 1) per start_time, marking which branch's data are treated as the actuals for the committed / realized-dispatch results.", "solve_advanced" ], [ diff --git a/flextool/update_flextool/db_migration.py b/flextool/update_flextool/db_migration.py index 6372827e4..7baed2189 100644 --- a/flextool/update_flextool/db_migration.py +++ b/flextool/update_flextool/db_migration.py @@ -4196,6 +4196,28 @@ def _migrate_v75(db) -> None: ) +_V76_DISPATCH_REALIZATION_METHOD_DESCRIPTION = ( + "How the stochastic branches of a solve are realized into a committed " + "dispatch. 'within_solve' (default) is the normal path: realization " + "stays inside the single optimisation — branches and their realized " + "periods are resolved within one solve, byte-identical to prior " + "behaviour. 'separate_dispatch' is an available advanced option that " + "splits realization into a dedicated downstream dispatch stage, " + "automatically spawning a separate realized-dispatch solve (a second " + "market-clearing pass over the revealed branch) — the 2-stage seed of " + "a future multi-stage sequential-market structure. Its " + "automatic-selection defaults are still being refined." +) +_V76_STOCHASTIC_BRANCHES_DESCRIPTION = ( + "[4d-Map], Sets branches included in the solve. [Period, branch, " + "start_time (time_step), realized (yes/no), weight (number)]. The " + "branches form a probability-weighted forecast fan (forecasts-only). " + "'realized' is an optional designation, at most one (0 or 1) per " + "start_time, marking which branch's data are treated as the actuals " + "for the committed / realized-dispatch results." +) + + def _migrate_v76_dispatch_realization_method(db) -> None: """Add the solve.dispatch_realization_method opt-in (v75 -> v76). @@ -4219,6 +4241,13 @@ def _migrate_v76_dispatch_realization_method(db) -> None: 'dispatch_realization_methods' value list; grouped under 'solve_advanced' (created if the DB lacks it). This is a structural clone of the v71 ``stochastic_invest_method`` line. + + This step also refreshes two stale user-facing descriptions to the + corrected stochastics model (description-only, no structural change): + ``solve.dispatch_realization_method`` (``separate_dispatch`` is now an + available advanced option, not "not yet implemented") and the older + ``solve.stochastic_branches`` ('realized' is an optional at-most-one + designation over a forecasts-only fan, not a one-of-N requirement). """ add_value_list_manual(db, [ ["dispatch_realization_methods", "within_solve"], @@ -4233,17 +4262,7 @@ def _migrate_v76_dispatch_realization_method(db) -> None: default_value=default_val, default_type=default_type, parameter_value_list_name="dispatch_realization_methods", - description=( - "How the stochastic branches of a solve are realized into a " - "committed dispatch. 'within_solve' (default) keeps realization " - "inside the single optimisation — branches and their realized " - "periods are resolved within one solve, byte-identical to prior " - "behaviour. 'separate_dispatch' splits realization into a " - "dedicated downstream dispatch stage (a second market-clearing " - "pass over the revealed branch); this is the 2-stage seed of a " - "future multi-stage sequential-market structure and is not yet " - "implemented (lands in a later slice)." - ), + description=_V76_DISPATCH_REALIZATION_METHOD_DESCRIPTION, ) # solve_advanced is this param's canonical home in the schema. _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) @@ -4254,6 +4273,20 @@ def _migrate_v76_dispatch_realization_method(db) -> None: parameter_group_name="solve_advanced", ) + # Correct the stale stochastic_branches description (an older param, + # created pre-v76 — line ~160) to the corrected stochastics model: the + # fan is a probability-weighted forecasts-only set and 'realized' is an + # optional designation (at most one per start_time), NOT a one-of-N + # requirement. Any DB upgrading to v76 passes through this step, so it + # is the natural place to refresh the user-facing text. Idempotent. + if _pdef_or_none(db, "solve", "stochastic_branches") is not None: + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="stochastic_branches", + description=_V76_STOCHASTIC_BRANCHES_DESCRIPTION, + ) + _commit_step(db, "v76: added solve.dispatch_realization_method " "(dispatch_realization_methods: within_solve/separate_dispatch, " diff --git a/tests/update_flextool/test_v76_dispatch_realization_method.py b/tests/update_flextool/test_v76_dispatch_realization_method.py index 57c7162b0..7bc886f12 100644 --- a/tests/update_flextool/test_v76_dispatch_realization_method.py +++ b/tests/update_flextool/test_v76_dispatch_realization_method.py @@ -98,6 +98,92 @@ def test_dispatch_realization_method_definition_present(tmp_path: Path) -> None: db.close() +_OLD_STOCHASTIC_BRANCHES_DESCRIPTION = ( + "[4d-Map], Sets branches included in the solve. [Period, branch, " + "start_time (time_step), realized (yes/no), weight (number)]. Only one " + "of the branches should be realized for each start_time" +) + + +def _build_v75_description_db(url: str) -> None: + """Seed a minimal v75 DB carrying the OLD ``solve.stochastic_branches`` + description and NO ``dispatch_realization_method`` — so the v76 step is + the only path that can correct the former and add the latter. + + Built from scratch (never the auto-migrated corpus, which already sits + AT v76 so the v76 block is skipped and no description refresh runs). + """ + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 75.0, None, "Database version."], + [ + "solve", + "stochastic_branches", + None, + None, + _OLD_STOCHASTIC_BRANCHES_DESCRIPTION, + ], + ], + alternatives=[["Base", ""]], + entities=[["solve", "s1"]], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v75 DB with stale stochastics descriptions") + + +def test_dispatch_realization_method_description_corrected( + tmp_path: Path, +) -> None: + """Migrating a below-v76 DB drops the stale "not yet implemented" clause + and frames ``separate_dispatch`` as an available advanced option + (W4b/W4c auto-spawn landed).""" + db_path = tmp_path / "v75_desc.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v75_description_db(url) + migrate_database(url, up_to=76) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="dispatch_realization_method", + ) + assert pdef, "dispatch_realization_method not added by migration" + desc = pdef["description"] + assert "available advanced option" in desc + assert "automatic-selection defaults are still being refined" in desc + assert "not yet implemented" not in desc + assert "lands in a later slice" not in desc + + +def test_stochastic_branches_description_corrected(tmp_path: Path) -> None: + """Migrating a below-v76 DB refreshes the older + ``solve.stochastic_branches`` description: 'realized' is an optional + at-most-one designation over a forecasts-only fan, not a one-of-N + requirement.""" + db_path = tmp_path / "v75_desc.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v75_description_db(url) + migrate_database(url, up_to=76) + with DatabaseMapping(url, create=False) as db: + db.fetch_all() + pdef = db.get_parameter_definition_item( + entity_class_name="solve", + name="stochastic_branches", + ) + assert pdef, "stochastic_branches definition missing" + desc = pdef["description"] + assert "optional designation" in desc + assert "at most one (0 or 1) per start_time" in desc + assert "forecasts-only" in desc + assert "Only one of the branches should be realized" not in desc + + def test_dispatch_realization_methods_value_list(tmp_path: Path) -> None: """``dispatch_realization_methods`` == {within_solve, separate_dispatch}.""" db = _migrated_db(tmp_path) From d639e1584efc6078a01d85aa8b0861b515c2a163 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 14 Oct 2026 11:43:15 +0300 Subject: [PATCH 85/87] fix(rolling): carry committed unit commitment across roll boundaries (OC1 carrier 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling unit-commitment correctness bug: the online dynamics read the predecessor at t_previous_within_solve, which at a roll's first step cyclically wraps to the roll's OWN last step. On a continuation roll that wrap is physically meaningless (the discarded look-ahead tail, not the prior roll's realized commitment), so a unit that stays committed across the boundary gets its startup RE-CHARGED every roll (over-counts startup cost; also lets units freely cycle across boundaries). Pre-existing in both the GMPL reference and the polars port. Fix (online analog of the storage p_roll_continue_state carry; internal/ derived — no schema, no migration): extract the committed jump-end v_online from the prior roll, carry it (SolveHandoff.roll_end_online), overlay it as p_roll_continue_online, and at the continuation roll's first step drop the spurious cyclic predecessor (slice the global-first (d,t) off the lag frame) and inject the carried commitment as a sparse Param((p,d,t)) on online__startup LHS / online__shutdown RHS, so v_startup >= v_online - carried (no startup when still committed; a genuine OFF->ON cold start has carried~0 and is still charged). Gated on carry-PRESENCE (p_roll_continue_online is not None), not on not-is_solve_first (which is inert on the standalone rolling-window path). Enum-cast the carried p-axis to online_set before the join. Multi-period roll windows exercised (witness spans 4 periods). Byte-parity: deterministic / first-solve / rolling-without-UC all take the unchanged else-branch (carrier None). Witness coal_min_load_fullYear_roll: realized v_startup 7.284 -> 5.854, startup cost 364,200 -> 292,700 EUR, 72/72 optimal. New tests/test_rolling_online_carry.py + strengthened test_min_load_rolling. Full suite 4152 passed. (Carrier 2 = min_uptime/ min_downtime across the boundary is a separate follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/_provider_keys.py | 2 + .../engine_polars/_provider_translators.py | 3 + flextool/engine_polars/_solve_handoff.py | 18 +- flextool/engine_polars/input.py | 104 ++++++++ flextool/engine_polars/model.py | 80 +++++- tests/test_min_load_rolling.py | 19 ++ tests/test_rolling_online_carry.py | 247 ++++++++++++++++++ 7 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 tests/test_rolling_online_carry.py diff --git a/flextool/engine_polars/_provider_keys.py b/flextool/engine_polars/_provider_keys.py index 38fed50bc..bdb13fd71 100644 --- a/flextool/engine_polars/_provider_keys.py +++ b/flextool/engine_polars/_provider_keys.py @@ -142,6 +142,7 @@ HANDOFF_DIVEST_CUMULATIVE = "handoff/divest_cumulative" HANDOFF_ROLL_END_STATE = "handoff/roll_end_state" HANDOFF_UPWARD_ROLL_END_STATE = "handoff/upward_roll_end_state" +HANDOFF_ROLL_END_ONLINE = "handoff/roll_end_online" HANDOFF_CUMULATIVE_CO2 = "handoff/cumulative_co2" HANDOFF_CUMULATIVE_COMMODITY = "handoff/cumulative_commodity" HANDOFF_CUM_SIM_HOURS = "handoff/cum_sim_hours" @@ -166,6 +167,7 @@ OVERRIDE_DIVEST_CUMULATIVE = "override/divest_cumulative" OVERRIDE_ROLL_END_STATE = "override/roll_end_state" OVERRIDE_UPWARD_ROLL_END_STATE = "override/upward_roll_end_state" +OVERRIDE_ROLL_END_ONLINE = "override/roll_end_online" OVERRIDE_CUMULATIVE_CO2 = "override/cumulative_co2" OVERRIDE_CUMULATIVE_COMMODITY = "override/cumulative_commodity" OVERRIDE_CUM_SIM_HOURS = "override/cum_sim_hours" diff --git a/flextool/engine_polars/_provider_translators.py b/flextool/engine_polars/_provider_translators.py index f079ded01..07aa3acc3 100644 --- a/flextool/engine_polars/_provider_translators.py +++ b/flextool/engine_polars/_provider_translators.py @@ -38,6 +38,7 @@ K.HANDOFF_DIVEST_CUMULATIVE: ("entity", "value"), K.HANDOFF_ROLL_END_STATE: ("node", "value"), K.HANDOFF_UPWARD_ROLL_END_STATE: ("node", "value"), + K.HANDOFF_ROLL_END_ONLINE: ("process", "value"), K.HANDOFF_CUMULATIVE_CO2: ("group", "period", "value"), K.HANDOFF_CUMULATIVE_COMMODITY: ("commodity", "tier", "period", "p_ladder_cum_realized_mwh"), @@ -77,6 +78,7 @@ def translate_handoff_to_provider( ("divest_cumulative", K.HANDOFF_DIVEST_CUMULATIVE), ("roll_end_state", K.HANDOFF_ROLL_END_STATE), ("upward_roll_end_state", K.HANDOFF_UPWARD_ROLL_END_STATE), + ("roll_end_online", K.HANDOFF_ROLL_END_ONLINE), ("cumulative_co2", K.HANDOFF_CUMULATIVE_CO2), ("cumulative_commodity", K.HANDOFF_CUMULATIVE_COMMODITY), ("cum_sim_hours", K.HANDOFF_CUM_SIM_HOURS), @@ -104,6 +106,7 @@ def translate_handoff_to_provider( K.HANDOFF_DIVEST_CUMULATIVE: K.OVERRIDE_DIVEST_CUMULATIVE, K.HANDOFF_ROLL_END_STATE: K.OVERRIDE_ROLL_END_STATE, K.HANDOFF_UPWARD_ROLL_END_STATE: K.OVERRIDE_UPWARD_ROLL_END_STATE, + K.HANDOFF_ROLL_END_ONLINE: K.OVERRIDE_ROLL_END_ONLINE, K.HANDOFF_CUMULATIVE_CO2: K.OVERRIDE_CUMULATIVE_CO2, K.HANDOFF_CUMULATIVE_COMMODITY: K.OVERRIDE_CUMULATIVE_COMMODITY, K.HANDOFF_CUM_SIM_HOURS: K.OVERRIDE_CUM_SIM_HOURS, diff --git a/flextool/engine_polars/_solve_handoff.py b/flextool/engine_polars/_solve_handoff.py index 69abf2d16..dc4eee8b2 100644 --- a/flextool/engine_polars/_solve_handoff.py +++ b/flextool/engine_polars/_solve_handoff.py @@ -26,6 +26,7 @@ realized_existing [entity, period, value] divest_cumulative [entity, value] roll_end_state [node, value] + roll_end_online [process, value] fix_storage_quantity [node, period, step, p_fix_storage_quantity] fix_storage_price [node, period, step, p_fix_storage_price] fix_storage_usage [node, period, step, p_fix_storage_usage] @@ -115,6 +116,21 @@ class SolveHandoff: # No file equivalent (in-memory only). upward_roll_end_state: pl.DataFrame | None = None + # End-of-roll committed online commitment for rolling unit-commitment + # carry-over — the online analog of ``roll_end_state``. Carries the + # realized committed-jump-end ``v_online`` (unit-count space, NOT + # ×unitsize) per online process so the next roll's + # ``online__startup`` / ``online__shutdown`` first-step rows see the + # prior roll's commitment as their predecessor instead of the + # meaningless within-roll cyclic wrap. Without it the startup cost is + # re-charged at every roll boundary even when the unit stays committed. + # Producer: prior roll's v_online_{linear,integer} at its last + # realized (d, t). + # Consumer: next roll's ``p_roll_continue_online`` overlay (applied at + # the continuation roll's first step by ``_add_online_block``). + # Schema: ``[process, value]``. No file equivalent (in-memory only). + roll_end_online: pl.DataFrame | None = None + # Storage quota imposed by parent solve on child solve — narrow # per-metric carriers in canonical column schema. The trio is # independent (parent may set quantity-only, price-only, or any @@ -142,7 +158,7 @@ class SolveHandoff: _FIELDS = ( "realized_invest", "realized_existing", "divest_cumulative", - "roll_end_state", "upward_roll_end_state", + "roll_end_state", "upward_roll_end_state", "roll_end_online", "fix_storage_quantity", "fix_storage_price", "fix_storage_usage", "cumulative_co2", "cumulative_commodity", "cum_sim_hours", ) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index e78307c9a..8329d7574 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -650,6 +650,14 @@ class FlexData: pdt_downtime_set: pl.DataFrame | None = None # (p, d, t) — minimum_downtime constraint domain uptime_lookback: pl.DataFrame | None = None # (p, d, t, d_back, t_back) — startup lookback window downtime_lookback: pl.DataFrame | None = None # (p, d, t, d_back, t_back) — shutdown lookback window + # Rolling online-commitment carry (online analog of + # ``p_roll_continue_state``). Carried committed-jump-end ``v_online`` + # of the prior roll per online process; applied at the continuation + # roll's first ``online__startup`` / ``online__shutdown`` step as the + # predecessor constant so a unit that stays committed is not re-charged + # a startup across the roll boundary. ``None`` on the first roll / + # non-rolling / no-online solves (→ byte-identical baseline path). + p_roll_continue_online: Param | None = None # (p,) # ─── Storage ───────────────────────────────────────────────────────── nodeState: pl.DataFrame | None = None @@ -2266,6 +2274,7 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, p_min_load=None, p_startup_cost=None, p_section=None, pdt_uptime_set=None, pdt_downtime_set=None, uptime_lookback=None, downtime_lookback=None, + p_roll_continue_online=None, ) if pss is None: return blank @@ -2357,6 +2366,38 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, # by ``apply_derived_c`` (helpers ``uptime_lookback_from_source`` / # ``downtime_lookback_from_source`` + ``pdt_uptime_set_from_lookback`` # / ``pdt_downtime_set_from_lookback``). Seeds dropped. + + # OC1 — rolling online-commitment carry. Read the committed-jump-end + # ``v_online`` handed off by the prior roll from the canonical handoff + # Provider key (``handoff/roll_end_online``, schema ``[process, + # value]``). The translator seeds this key from + # ``SolveHandoff.roll_end_online`` at iteration start; it is empty + # (→ ``None`` here) on the first roll / non-rolling / no-prior-carrier + # solves, so the online dynamics keep their byte-identical baseline + # path. Mirrors the storage ``p_roll_continue_state`` overlay + # (:func:`_load_storage`) but lives here because online can exist + # without storage (``_load_storage`` short-circuits when there is no + # ``nodeState``, so it would never read this carrier for a UC-only + # fixture). The gate that makes the carry fire is carry-PRESENCE + # (``p_roll_continue_online is not None``), NOT ``not is_solve_first``: + # on the standalone rolling-window path ``p_nested_solve_first`` is + # ``None`` every roll, so the storage gate's ``not is_solve_first`` + # clause is inert there and carry-presence is the operative signal (it + # is exactly co-extensive with "continuation roll" — empty on roll 1). + from flextool.engine_polars import _provider_keys as K + from flextool.engine_polars._provider_translators import read_handoff_frame + p_roll_continue_online = None + df_rco = read_handoff_frame(provider, K.HANDOFF_ROLL_END_ONLINE) + if df_rco is not None and df_rco.height > 0: + df_rco = (df_rco + .pipe(rename_to_axis, {"process": "p"}) + .with_columns(value=pl.col("value") + .cast(pl.Float64, strict=False) + .fill_null(0.0)) + .select("p", "value")) + if df_rco.height > 0: + p_roll_continue_online = Param(("p",), df_rco) + return dict( process_online=p_online, process_online_linear=p_online_lin, @@ -2373,6 +2414,7 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, pdt_downtime_set=None, uptime_lookback=None, downtime_lookback=None, + p_roll_continue_online=p_roll_continue_online, ) @@ -6071,6 +6113,67 @@ def build_handoff_from_solution( # child by topology rather than completion order). upward_roll_end_state_df = roll_end_state_df + # ---- roll_end_online: committed-jump-end v_online per online process ---- + # OC1 — the online analog of ``roll_end_state`` (above). Carries the + # realized committed-jump-end ``v_online`` so the next roll's + # ``online__startup`` / ``online__shutdown`` first-step rows see the + # prior roll's commitment as predecessor instead of the within-roll + # cyclic wrap (which re-charges a startup at every boundary). Kept in + # unit-count space (NOT ×unitsize): the online dynamics operate on + # ``v_online ∈ [0, existing_count]``, the same units the cyclic lag + # carries. Self-contained (own realized-dispatch read + last-(d,t) + # pick) because online can exist without any nodeState, so it must not + # depend on the storage block above having run. Keyed on ``p`` alone + # (a process has one startup_method at a time, so linear/integer never + # mix for the same ``p``); linear and integer rows are unioned. + roll_end_online_df = None + rd_path_on = sd / "realized_dispatch.csv" + last_pairs_on = None + if _provider_has(provider, "solve_data/realized_dispatch", rd_path_on): + try: + last_pairs_on = _provider_read( + provider, "solve_data/realized_dispatch", rd_path_on) + except pl.exceptions.NoDataError: + last_pairs_on = None + if (last_pairs_on is None and flex_data is not None + and getattr(flex_data, "realized_dispatch", None) is not None): + last_pairs_on = flex_data.realized_dispatch + _has_online_var = ("v_online_linear" in sol._vars + or "v_online_integer" in sol._vars) + if last_pairs_on is not None and last_pairs_on.height > 0 and _has_online_var: + cols = last_pairs_on.columns + d_col = "period" if "period" in cols else "d" + t_col = "step" if "step" in cols else "t" + lp_on = (last_pairs_on + .select(alias_to_axis(d_col, "d"), alias_to_axis(t_col, "t")) + .unique() + .sort(["d", "t"])) + if lp_on.height > 0: + last_d_on = lp_on["d"][-1] + last_t_on = lp_on["t"][-1] + rco_rows: list[tuple[str, float]] = [] + for _vname in ("v_online_linear", "v_online_integer"): + if _vname not in sol._vars: + continue + _v = sol._vars[_vname] + # col_id-filtered pull (mirrors the memory-frugal v_state + # path above): filter the variable's col_id frame to the + # single committed-jump-end (d, t), then read values for + # those rows alone instead of materialising the full frame. + _last_frame = _v.frame.filter( + (pl.col("d") == last_d_on) & (pl.col("t") == last_t_on)) + if _last_frame.height == 0: + continue + _ids = _last_frame["col_id"].to_numpy() + _vals = sol.col_value[_ids] + _lst = _last_frame.select(*_v.dims).with_columns( + value=pl.Series(_vals)) + for r in _lst.iter_rows(named=True): + rco_rows.append((str(r["p"]), float(r["value"]))) + if rco_rows: + roll_end_online_df = pl.DataFrame( + rco_rows, schema=["process", "value"], orient="row") + # ---- fix_storage_quantity: v_state at fix_quantity timesteps × unitsize ---- # Mirrors flextool's ``write_fix_storage_quantity`` (handoff_writers.py # :380). Restricted to nodes whose storage_nested_fix_method is @@ -6685,6 +6788,7 @@ def build_handoff_from_solution( ) if div_rows else None, roll_end_state=roll_end_state_df, upward_roll_end_state=upward_roll_end_state_df, + roll_end_online=roll_end_online_df, fix_storage_quantity=fix_storage_quantity_df, fix_storage_price=fix_storage_price_df, fix_storage_usage=fix_storage_usage_df, diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 920598cc9..6670d1bfe 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -5193,15 +5193,77 @@ def _lhs_with_invdiv(var, key): rhs_terms={"max_units": d.p_process_existing_count}) # online dynamics: v_startup[t] >= v_online[t] - v_online[t-1] # v_shutdown[t] >= v_online[t-1] - v_online[t] - v_online_lag = Lag(v_online, d.dtttdt, "t", "t_previous_within_solve") - m.add_cstr(f"online__startup{sfx}", over=p_idx, sense=">=", - lhs_terms={"startup": v_startup, - "online_prev": v_online_lag}, - rhs_terms={"online_now": v_online}) - m.add_cstr(f"online__shutdown{sfx}", over=p_idx, sense=">=", - lhs_terms={"shutdown": v_shutdown, - "online_now": v_online}, - rhs_terms={"online_prev": v_online_lag}) + # + # OC1 — rolling online-commitment carry. ``Lag`` reads the predecessor + # at ``t_previous_within_solve``, which at the first step of the first + # period CYCLICALLY WRAPS to the solve's own last step + # (_block_layout.py:326-333). On a *continuation* roll that wrap is + # physically meaningless (it references the discarded look-ahead tail, + # not the prior roll's realized commitment) — so a unit that stays + # committed across the boundary is re-charged a full startup every roll. + # The fix is the online analog of the storage ``roll_continue`` carry: + # on a continuation roll, drop the spurious cyclic predecessor at the + # boundary (slice the global-first (d, t) off the lag frame — the same + # ``dtttdt.slice(1)`` trick ``dtttdt_forward_only`` uses) and inject the + # carried prior-roll committed ``v_online`` as the first-step predecessor + # constant. Then ``v_startup >= v_online - carried`` (no startup when + # the unit stays committed; a genuine OFF→ON cold start has carried≈0 and + # is still charged). + # + # Gate on carry-PRESENCE (``d.p_roll_continue_online is not None``), NOT + # on ``not is_solve_first``: on the standalone rolling-window path + # ``p_nested_solve_first`` is ``None`` every roll (verified live), so the + # storage gate's ``not is_solve_first`` clause is inert there. Carry- + # presence is the operative signal and is exactly co-extensive with + # "continuation roll" (the handoff carrier is empty on roll 1 → ``None`` + # here → byte-identical baseline path; first-solve / non-rolling / + # no-online solves likewise leave it ``None``). + rco = d.p_roll_continue_online + if rco is not None and d.dtttdt is not None and d.dtttdt.height > 0: + dtttdt_cont = d.dtttdt.sort("d", "t").slice(1) + first_dt = d.dtttdt.sort("d", "t").select("d", "t").head(1) + # Enum-cast the carried p-axis (String, from the handoff frame) to + # the model's ``online_set`` p Enum axis before the join; a cross- + # dtype (String vs Enum) join raises SchemaError. Filtering to + # this kind's ``online_set`` keeps only the processes this block + # owns (a process has one startup_method, so linear/integer never + # collide on the same ``p``). + _p_dt = online_set.schema["p"] + carry_al = rco.frame.with_columns( + pl.col("p").cast(_p_dt, strict=False)) + p_carry_rows = (online_set.select("p").unique() + .join(carry_al, on="p", how="inner") + .join(first_dt, how="cross") + .select("p", "d", "t", "value")) + v_online_lag = Lag(v_online, dtttdt_cont, "t", + "t_previous_within_solve") + su_lhs = {"startup": v_startup, "online_prev": v_online_lag} + sd_rhs = {"online_prev": v_online_lag} + if p_carry_rows.height > 0: + # +carried predecessor at the boundary row only — sparse + # Param((p, d, t)); the engine's Param+Param outer-joins with + # 0-fill so it composes with the (boundary-dropped) cyclic lag + # without perturbing any other row. Exactly the + # ``roll_continue_value`` construction (nodeBalance_eq). + su_lhs["roll_continue_online"] = Param(("p", "d", "t"), p_carry_rows) + sd_rhs["roll_continue_online"] = Param(("p", "d", "t"), p_carry_rows) + m.add_cstr(f"online__startup{sfx}", over=p_idx, sense=">=", + lhs_terms=su_lhs, + rhs_terms={"online_now": v_online}) + m.add_cstr(f"online__shutdown{sfx}", over=p_idx, sense=">=", + lhs_terms={"shutdown": v_shutdown, + "online_now": v_online}, + rhs_terms=sd_rhs) + else: + v_online_lag = Lag(v_online, d.dtttdt, "t", "t_previous_within_solve") + m.add_cstr(f"online__startup{sfx}", over=p_idx, sense=">=", + lhs_terms={"startup": v_startup, + "online_prev": v_online_lag}, + rhs_terms={"online_now": v_online}) + m.add_cstr(f"online__shutdown{sfx}", over=p_idx, sense=">=", + lhs_terms={"shutdown": v_shutdown, + "online_now": v_online}, + rhs_terms={"online_prev": v_online_lag}) # maxFlow_online: v_flow <= v_online * availability (assumes # max_cap_coef=1). The .mod's RHS for online processes is diff --git a/tests/test_min_load_rolling.py b/tests/test_min_load_rolling.py index 64a90117f..93309f41a 100644 --- a/tests/test_min_load_rolling.py +++ b/tests/test_min_load_rolling.py @@ -129,3 +129,22 @@ def test_rolling_min_load_efficiency_solves( # positive objective (the MLE coal unit carries fuel cost). obj = _read_objective(workdir) assert obj > 0 and pd.notna(obj), f"unexpected objective {obj!r}" + + # OC1 — the rolling online-commitment carry must suppress the spurious + # per-boundary startup re-charge. Pre-OC1 the cyclic-wrap predecessor + # re-charged the coal unit's startup at every continuation-roll first + # step, inflating the realized yearly v_startup to 7.284 (startup cost + # 364 200 €); with the carry it drops to the honest 5.854 (292 700 €). + # Assert the CORRECTED value so a regression that re-breaks the carry + # (reverting to the over-charged total) fails here, not just silently. + import polars as pl + + su_files = sorted((workdir / "output_raw").glob("v_startup_linear__*.parquet")) + assert su_files, "no realized v_startup_linear output" + total_startup = sum( + float(pl.read_parquet(f)["coal_plant"].sum()) for f in su_files + ) + assert total_startup == pytest.approx(5.854, abs=5e-3), ( + f"realized yearly coal v_startup={total_startup} " + f"(expected the OC1-corrected 5.854, pre-fix was the over-charged 7.284)" + ) diff --git a/tests/test_rolling_online_carry.py b/tests/test_rolling_online_carry.py new file mode 100644 index 000000000..b024b1c65 --- /dev/null +++ b/tests/test_rolling_online_carry.py @@ -0,0 +1,247 @@ +"""Regression: rolling online-commitment carry (OC1, Carrier 1). + +A rolling-window unit-commitment solve used to re-charge a unit's startup +cost at EVERY roll boundary, even when the unit stayed committed across +the boundary. The online dynamics reference the predecessor commitment +at ``t_previous_within_solve``, which at a roll's first step cyclically +wraps to the roll's OWN last look-ahead step (``_block_layout.py``) — a +physically meaningless value on a continuation roll, with zero memory of +the prior roll's realized commitment. + +The fix is the online analog of the storage ``roll_end_state → +p_roll_continue_state`` carry (purely internal / derived — no schema, no +migration): the prior roll's committed-jump-end ``v_online`` is carried as +``SolveHandoff.roll_end_online`` and overlaid as +``FlexData.p_roll_continue_online``; at the continuation roll's first step +the spurious cyclic predecessor is sliced out of the online Lag frame and +the carried value is injected as the predecessor constant, so:: + + v_startup[first] >= v_online[first] - carried + +(no startup when the unit stays committed; a genuine OFF→ON start still +pays — ``carried ≈ 0`` leaves the full charge). + +Driven witness: ``coal_min_load_fullYear_roll`` (a ``min_load_efficiency`` +coal unit on the fullYear timeline, ``rolling_solve_horizon=8``, +``rolling_solve_jump=4`` → 72 rolls spanning p2020/p2025/p2030/p2035, so +multiple-period roll windows are exercised). Pre-fix the realized coal +``v_startup`` summed to 7.284 (startup cost 364 200 €); with the carry the +boundary over-charges vanish and it drops to 5.854 (292 700 €). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import polars as pl +import pytest + +TEST_DIR = Path(__file__).parent +if str(TEST_DIR) not in sys.path: + sys.path.insert(0, str(TEST_DIR)) + +from db_utils import json_to_db # noqa: E402 + +from flextool.engine_polars import run_chain_from_db # noqa: E402 +from flextool.update_flextool.db_migration import migrate_database # noqa: E402 + +pytestmark = [pytest.mark.solver] + +SCENARIO = "coal_min_load_fullYear_roll" +UNIT = "coal_plant" +STARTUP_COST = 100.0 # €/unit-start +UNITSIZE = 500.0 # MW → objective coef = STARTUP_COST * UNITSIZE + +# Driven baselines (see module docstring). Pre-fix the cyclic-wrap +# re-charge inflated the yearly realized startup to 7.284; the carry +# removes exactly the boundary over-charges. +CORRECTED_TOTAL_STARTUP = 5.854 +PREFIX_TOTAL_STARTUP = 7.284 + + +def _add_scenario(db_url: str) -> None: + from spinedb_api import DatabaseMapping, import_data + + with DatabaseMapping(db_url) as db_map: + _, errors = import_data( + db_map, + scenarios=[(SCENARIO, False, "")], + scenario_alternatives=[ + (SCENARIO, "init", "west"), + (SCENARIO, "west", "coal"), + (SCENARIO, "coal", "coal_min_load"), + (SCENARIO, "coal_min_load", "wind"), + (SCENARIO, "wind", "fullYear"), + (SCENARIO, "fullYear", "dispatch_fullYear_roll"), + (SCENARIO, "dispatch_fullYear_roll", None), + ], + ) + if errors: + raise RuntimeError(f"Import errors: {errors}") + db_map.commit_session("Add rolling online-carry witness scenario") + + +@pytest.fixture(scope="module") +def rolling_uc(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Drive the witness ONCE with ``keep_solutions=True`` so the per-roll + ``OrchestrationStep`` (``handoff`` producer + ``flex_data`` consumer) + and the realized ``output_raw`` parquets are both available.""" + db_path = tmp_path_factory.mktemp("db_oc1") / "tests.sqlite" + url = json_to_db(TEST_DIR / "fixtures" / "tests.json", db_path) + migrate_database(url, up_to=40) + _add_scenario(url) + workdir = tmp_path_factory.mktemp("oc1_run") + steps = run_chain_from_db( + url, SCENARIO, work_folder=workdir, keep_solutions=True, + ) + return {"steps": steps, "workdir": workdir} + + +def _roll_index(name: str) -> int: + return int(name.split("roll_")[-1]) + + +def _ordered_steps(result: dict): + return sorted(result["steps"].items(), key=lambda kv: _roll_index(kv[0])) + + +def _per_roll_frame(workdir: Path, key: str) -> dict[int, pl.DataFrame]: + raw = workdir / "output_raw" + out: dict[int, pl.DataFrame] = {} + for f in raw.glob(f"{key}__*.parquet"): + out[_roll_index(f.stem)] = pl.read_parquet(f).sort(["period", "time"]) + return out + + +def _carried_value(step, unit: str) -> float | None: + """The committed value this roll CONSUMED (overlaid carrier), or None.""" + p = getattr(step.flex_data, "p_roll_continue_online", None) + if p is None: + return None + row = p.frame.filter(pl.col("p").cast(pl.Utf8) == unit) + return float(row["value"][0]) if row.height else 0.0 + + +def _produced_value(step, unit: str) -> float | None: + """The committed jump-end value this roll PRODUCED, or None.""" + ho = getattr(step, "handoff", None) + frame = None if ho is None else getattr(ho, "roll_end_online", None) + if frame is None: + return None + row = frame.filter(pl.col("process").cast(pl.Utf8) == unit) + return float(row["value"][0]) if row.height else 0.0 + + +def test_all_rolls_optimal(rolling_uc): + """Every roll of the rolling UC solve reaches optimality with the carry.""" + steps = rolling_uc["steps"] + assert len(steps) == 72, f"expected 72 rolls, got {len(steps)}" + assert all(s.optimal for s in steps.values()), { + n: s.optimal for n, s in steps.items() if not s.optimal + } + + +def test_carrier_produced_and_consumed(rolling_uc): + """The online carrier is produced by each roll and consumed by the next. + + Producer-level (HEAD has no ``roll_end_online`` / ``p_roll_continue_online`` + field). The first roll consumes NO carrier (byte-identical baseline); + every continuation roll consumes exactly the prior roll's committed + jump-end ``v_online``. + """ + ordered = _ordered_steps(rolling_uc) + + # Roll 0 consumes nothing (no prior handoff) and still produces a carrier. + first_step = ordered[0][1] + assert _carried_value(first_step, UNIT) is None, ( + "first roll must consume no online carrier (byte-identical path)" + ) + assert _produced_value(first_step, UNIT) is not None, ( + "first roll must still PRODUCE a roll_end_online carrier for roll 1" + ) + + # Each continuation roll consumes exactly the prior roll's produced value. + for i in range(1, len(ordered)): + prior = _produced_value(ordered[i - 1][1], UNIT) + consumed = _carried_value(ordered[i][1], UNIT) + assert consumed is not None, ( + f"roll {_roll_index(ordered[i][0])} consumed no carrier" + ) + assert consumed == pytest.approx(prior, abs=1e-9), ( + f"roll {_roll_index(ordered[i][0])}: consumed carrier {consumed} " + f"!= prior roll's committed jump-end {prior}" + ) + + +def test_total_startup_drops_to_corrected_value(rolling_uc): + """The yearly realized startup drops to the corrected (no boundary + re-charge) value — strictly below the pre-fix over-charged total.""" + su = _per_roll_frame(rolling_uc["workdir"], "v_startup_linear") + total = sum(float(df[UNIT].sum()) for df in su.values()) + assert total == pytest.approx(CORRECTED_TOTAL_STARTUP, abs=5e-3), ( + f"realized yearly v_startup={total} (expected {CORRECTED_TOTAL_STARTUP})" + ) + assert total < PREFIX_TOTAL_STARTUP - 1.0, ( + f"startup {total} not meaningfully below pre-fix {PREFIX_TOTAL_STARTUP}" + ) + # Objective-coef translation (startup_cost × unitsize): the saved cost. + saved = (PREFIX_TOTAL_STARTUP - total) * STARTUP_COST * UNITSIZE + assert saved == pytest.approx(71_500.0, abs=500.0), ( + f"saved startup cost {saved} € off expected ≈71 500 €" + ) + + +def test_boundary_startup_equals_increment(rolling_uc): + """At every continuation-roll first step the startup equals the honest + increment ``max(0, v_online_first - carried)`` — NOT the full online + (which the cyclic-wrap bug charged). This single invariant encodes + both suppression (committed → carried>0 → little/no startup) and the + cold-start charge (carried≈0 → full startup).""" + ordered = _ordered_steps(rolling_uc) + on = _per_roll_frame(rolling_uc["workdir"], "v_online_linear") + su = _per_roll_frame(rolling_uc["workdir"], "v_startup_linear") + + committed_suppressed = 0 + for i in range(1, len(ordered)): + r = _roll_index(ordered[i][0]) + online_first = float(on[r][UNIT][0]) + startup_first = float(su[r][UNIT][0]) + carried = _carried_value(ordered[i][1], UNIT) + expected = max(0.0, online_first - carried) + assert startup_first == pytest.approx(expected, abs=1e-2), ( + f"roll {r}: startup_first={startup_first} != " + f"max(0, online_first={online_first} - carried={carried})={expected}" + ) + # Suppression witness: a unit committed into this roll (carried>0) + # whose first step keeps it on must NOT be re-charged the full online. + if carried > 0.1 and online_first > carried + 1e-6: + assert startup_first < online_first - 1e-3, ( + f"roll {r}: committed unit re-charged a full startup " + f"({startup_first} ≈ online {online_first})" + ) + committed_suppressed += 1 + assert committed_suppressed > 0, ( + "no committed-across-boundary roll witnessed — witness fixture changed?" + ) + + +def test_genuine_cold_start_still_charged(rolling_uc): + """The fix is surgical to the roll boundary: a genuine OFF→ON start + (predecessor ≈ 0) still pays its full startup. Witnessed by a mid-roll + off→on step, whose cyclic lag the carry never touches.""" + on = _per_roll_frame(rolling_uc["workdir"], "v_online_linear") + su = _per_roll_frame(rolling_uc["workdir"], "v_startup_linear") + + found = False + for r in sorted(on): + o = on[r][UNIT].to_list() + s = su[r][UNIT].to_list() + for k in range(1, len(o)): # mid-roll steps only + if o[k - 1] < 1e-3 and o[k] > 0.05: + # off→on within the roll → full startup charged. + assert s[k] == pytest.approx(o[k], abs=1e-2), ( + f"roll {r} step {k}: genuine cold start not fully charged " + f"(startup {s[k]} vs online {o[k]})" + ) + found = True + assert found, "no mid-roll cold start witnessed — witness fixture changed?" From e8ef1aba77b0c7760047e320fb4dce87d403f752 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Wed, 14 Oct 2026 12:16:47 +0300 Subject: [PATCH 86/87] fix(rolling): build min_uptime/min_downtime lookback on the synthetic path (MINTIME-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling correctness bug: min_uptime/min_downtime were silently NOT enforced in rolling solves — not just across roll boundaries, within rolls too. The synthetic/rolling early-return in _apply_db_overrides re-wires several derived gaps (RESERVE-1/MINLOAD-1/NA-1/...) then returns before building the uptime/downtime lookback, whose sole producer is apply_derived_c; so the minimum_uptime/minimum_downtime constraints were emitted with None inputs and dropped. Same class of bug as W3. Fix: add a MINTIME-1 re-wire alongside the others — build the uptime/downtime lookback + pdt uptime set on the synthetic FlexData (mirroring apply_derived_c §3.8), so the constraints fire in rolling. Internal/derived — no schema. Witness coal_min_updown_roll (min_uptime=min_downtime=5, horizon=8, jump=4): min-up/down now enforced within every roll (roll-0 v_online holds at 0.152 instead of collapsing to 0.044 below its own startup obligation; 0 within-roll violations). Cross-boundary obligation continuity remains the follow-up (OC1 Carrier 2). Byte-parity: deterministic/single solves take the full apply_derived_c path (unchanged); rolling solves without min-up/down no-op (builders return None). New tests/test_rolling_online_minuptime.py; Carrier-1 byte-parity witness + rolling-stochastic unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZpCPYcqig42z8JQW3Qj5c --- flextool/engine_polars/input.py | 39 +++++ tests/test_rolling_online_minuptime.py | 204 +++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 tests/test_rolling_online_minuptime.py diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index 8329d7574..e042ba71c 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -5163,6 +5163,45 @@ def _wire_non_anticipativity_for_synthetic_solve(): "apply_db_overrides:pass_synthetic_non_anticipativity") _timed("c.synth non_anticipativity", _wire_non_anticipativity_for_synthetic_solve) + + # MINTIME-1 — same class of gap as RESERVE-1 / SECTION-1 / + # MINLOAD-1 / INFLOW-1. The synthetic-solve early-return skips + # passes 3-10, so ``apply_derived_c``'s (``_derived_params`` §3.8) + # min_uptime / min_downtime lookback producers never run on a + # rolling sub-solve — ``uptime_lookback`` / ``pdt_uptime_set`` (and + # the downtime twins) are the SOLE inputs to ``model.py``'s + # ``minimum_uptime`` / ``minimum_downtime`` constraints, so with + # them left ``None`` the constraints are silently dropped even + # WITHIN a roll (not just at the boundary). A unit authored with + # ``min_uptime`` / ``min_downtime`` is then free to collapse below + # its own recent startup obligation inside a single roll. + # ``uptime_lookback_from_source`` / ``downtime_lookback_from_source`` + # are solve-agnostic (no ``active_solve`` filter) and build the + # lookback window over the CSV-loaded ``flex_data.dt`` (+ + # ``p_step_duration``), both populated at ``FlexData`` construction + # before this early-return, so calling them here mirrors + # ``apply_derived_c`` exactly. Both helpers return ``None`` when no + # process authors ``min_uptime`` / ``min_downtime`` (``_process_min_time`` + # empty), so this is a no-op — byte-identical — on every rolling + # fixture that does not use min-up/down. Cross-boundary obligation + # continuity (an obligation opened in roll N constraining roll N+1's + # early steps) is a SEPARATE following slice (Carrier 2); this wire + # restores WITHIN-roll enforcement only. + def _wire_mintime_for_synthetic_solve(): + dt = getattr(flex_data, "dt", None) + sd = getattr(flex_data, "p_step_duration", None) + ulb = _drv.uptime_lookback_from_source(db_reader, dt, sd) + if ulb is not None and ulb.height > 0: + flex_data.uptime_lookback = ulb + flex_data.pdt_uptime_set = ( + _drv.pdt_uptime_set_from_lookback(ulb)) + dlb = _drv.downtime_lookback_from_source(db_reader, dt, sd) + if dlb is not None and dlb.height > 0: + flex_data.downtime_lookback = dlb + flex_data.pdt_downtime_set = ( + _drv.pdt_downtime_set_from_lookback(dlb)) + _loadflex_prof("apply_db_overrides:pass_synthetic_mintime") + _timed("c.synth mintime", _wire_mintime_for_synthetic_solve) return _loadflex_prof("apply_db_overrides:pass_derived_a") diff --git a/tests/test_rolling_online_minuptime.py b/tests/test_rolling_online_minuptime.py new file mode 100644 index 000000000..ea5710820 --- /dev/null +++ b/tests/test_rolling_online_minuptime.py @@ -0,0 +1,204 @@ +"""Regression: min_uptime / min_downtime enforcement in ROLLING solves +(MINTIME-1, the prerequisite layer of OC1 Carrier 2). + +On a rolling / synthetic sub-solve the online min_uptime / min_downtime +lookback (``uptime_lookback`` / ``pdt_uptime_set`` and the downtime twins) +was **never built** — the synthetic-solve early-return in +``input._apply_db_overrides`` skips ``apply_derived_c`` (§3.8), the sole +producer of those frames, and returned without re-wiring them (unlike the +documented RESERVE-1 / SECTION-1 / MINLOAD-1 / INFLOW-1 / NA-1 gaps). With +the frames ``None`` the ``minimum_uptime`` / ``minimum_downtime`` constraints +(``model.py`` ``_add_online_block``) were silently dropped **even within a +single roll**, so a unit authored with ``min_uptime`` / ``min_downtime`` was +free to collapse below its own recent startup obligation inside a roll. + +The fix wires ``uptime_lookback_from_source`` / +``downtime_lookback_from_source`` (+ the ``pdt_*_set`` projections) into the +synthetic early-return, exactly mirroring ``apply_derived_c`` §3.8. Both +helpers return ``None`` when no process authors min-up/down, so the wire is a +no-op (byte-identical) on every rolling fixture without min-up/down. + +Scope: MINTIME-1 is **within-roll** enforcement only. Cross-boundary +obligation continuity (an obligation opened in roll N constraining roll N+1's +early steps) is a SEPARATE following slice (OC1 Carrier 2) and is NOT asserted +here — boundary violations may still remain after this slice, by design. + +Driven witness: ``coal_min_updown_roll`` — a ``min_load_efficiency`` coal +unit with ``min_uptime = min_downtime = 5`` and ``minimum_time_method="both"`` +on the fullYear rolling timeline (``rolling_solve_horizon=8``, +``rolling_solve_jump=4`` → 72 rolls, ``W=5 > jump=4``). Pre-fix the realized +roll-0 ``v_online`` collapsed to ``[0.418, 0.274, 0.152, 0.044]`` below its own +startup obligation; with the wire it holds at ``[0.418, 0.274, 0.152, 0.152]``. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import polars as pl +import pytest + +TEST_DIR = Path(__file__).parent +if str(TEST_DIR) not in sys.path: + sys.path.insert(0, str(TEST_DIR)) + +from db_utils import json_to_db # noqa: E402 + +from flextool.engine_polars import run_chain_from_db # noqa: E402 +from flextool.update_flextool.db_migration import migrate_database # noqa: E402 + +pytestmark = [pytest.mark.solver] + +SCENARIO = "coal_min_updown_roll" +UNIT = "coal_plant" +MIN_TIME = 5 # min_uptime = min_downtime (hourly steps → W = 5 steps) +EXISTING_COUNT = 1.0 # existing=500, unitsize=500 → one unit, v_online∈[0,1] + + +def _add_scenario(db_url: str) -> None: + """Insert the witness scenario — the Carrier-1 chain with the + ``coal_min_updowntime`` alternative inserted (sets ``min_uptime=5`` / + ``min_downtime=5`` / ``minimum_time_method="both"`` on ``coal_plant``). + Built from JSON per CLAUDE.md invariant #3 (never a checked-in .sqlite). + """ + from spinedb_api import DatabaseMapping, import_data + + with DatabaseMapping(db_url) as db_map: + _, errors = import_data( + db_map, + scenarios=[(SCENARIO, False, "")], + scenario_alternatives=[ + (SCENARIO, "init", "west"), + (SCENARIO, "west", "coal"), + (SCENARIO, "coal", "coal_min_load"), + (SCENARIO, "coal_min_load", "coal_min_updowntime"), + (SCENARIO, "coal_min_updowntime", "wind"), + (SCENARIO, "wind", "fullYear"), + (SCENARIO, "fullYear", "dispatch_fullYear_roll"), + (SCENARIO, "dispatch_fullYear_roll", None), + ], + ) + if errors: + raise RuntimeError(f"Import errors: {errors}") + db_map.commit_session("Add rolling min-up/down witness scenario") + + +@pytest.fixture(scope="module") +def rolling_uc(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Drive the witness ONCE with ``keep_solutions=True`` so each per-roll + ``flex_data`` (the MINTIME-1 lookback consumer) and the realized + ``output_raw`` parquets are both available.""" + db_path = tmp_path_factory.mktemp("db_mintime") / "tests.sqlite" + url = json_to_db(TEST_DIR / "fixtures" / "tests.json", db_path) + migrate_database(url, up_to=40) + _add_scenario(url) + workdir = tmp_path_factory.mktemp("mintime_run") + steps = run_chain_from_db( + url, SCENARIO, work_folder=workdir, keep_solutions=True, + ) + return {"steps": steps, "workdir": workdir} + + +def _roll_index(name: str) -> int: + return int(name.split("roll_")[-1]) + + +def _per_roll_frame(workdir: Path, key: str) -> dict[int, pl.DataFrame]: + raw = workdir / "output_raw" + out: dict[int, pl.DataFrame] = {} + for f in raw.glob(f"{key}__*.parquet"): + out[_roll_index(f.stem)] = pl.read_parquet(f).sort(["period", "time"]) + return out + + +def test_all_rolls_optimal(rolling_uc): + """Every roll of the rolling min-up/down UC solve reaches optimality.""" + steps = rolling_uc["steps"] + assert len(steps) == 72, f"expected 72 rolls, got {len(steps)}" + assert all(s.optimal for s in steps.values()), { + n: s.optimal for n, s in steps.items() if not s.optimal + } + + +def test_mintime_lookback_built_each_roll(rolling_uc): + """MINTIME-1 emission: every rolling sub-solve builds the min_uptime / + min_downtime lookback + projected pdt set (HEAD-failing — all ``None`` + on the synthetic path, so the constraints were silently absent).""" + for name, step in rolling_uc["steps"].items(): + fd = step.flex_data + ulb = getattr(fd, "uptime_lookback", None) + pds = getattr(fd, "pdt_uptime_set", None) + dlb = getattr(fd, "downtime_lookback", None) + pdd = getattr(fd, "pdt_downtime_set", None) + assert ulb is not None and ulb.height > 0, ( + f"{name}: uptime_lookback not built on rolling sub-solve" + ) + assert pds is not None and pds.height > 0, ( + f"{name}: pdt_uptime_set not built on rolling sub-solve" + ) + assert dlb is not None and dlb.height > 0, ( + f"{name}: downtime_lookback not built on rolling sub-solve" + ) + assert pdd is not None and pdd.height > 0, ( + f"{name}: pdt_downtime_set not built on rolling sub-solve" + ) + + +def test_within_roll_min_uptime_enforced(rolling_uc): + """Within each roll, ``v_online[k] >= Σ v_startup`` over the within-roll + min_uptime window (the ``MIN_TIME`` consecutive steps ending at ``k``). + HEAD-failing: roll 0 collapses to ``online[3]=0.044`` under its own + ``startup[0]=0.152``. Cross-boundary rows are NOT checked (Carrier 2).""" + on = _per_roll_frame(rolling_uc["workdir"], "v_online_linear") + su = _per_roll_frame(rolling_uc["workdir"], "v_startup_linear") + + violations = [] + for r in sorted(on): + o = on[r][UNIT].to_list() + s = su[r][UNIT].to_list() + for k in range(len(o)): + lo = max(0, k - MIN_TIME + 1) + startup_sum = sum(s[lo:k + 1]) + if o[k] < startup_sum - 1e-6: + violations.append((r, k, o[k], startup_sum)) + assert not violations, ( + f"within-roll min_uptime violated at {violations[:5]} " + f"({len(violations)} total)" + ) + + +def test_within_roll_min_downtime_enforced(rolling_uc): + """Within each roll, ``v_online[k] + Σ v_shutdown <= existing_count`` over + the within-roll min_downtime window. Cross-boundary rows are NOT checked + (Carrier 2).""" + on = _per_roll_frame(rolling_uc["workdir"], "v_online_linear") + sd = _per_roll_frame(rolling_uc["workdir"], "v_shutdown_linear") + + violations = [] + for r in sorted(on): + o = on[r][UNIT].to_list() + s = sd[r][UNIT].to_list() + for k in range(len(o)): + lo = max(0, k - MIN_TIME + 1) + shutdown_sum = sum(s[lo:k + 1]) + if o[k] + shutdown_sum > EXISTING_COUNT + 1e-6: + violations.append((r, k, o[k], shutdown_sum)) + assert not violations, ( + f"within-roll min_downtime violated at {violations[:5]} " + f"({len(violations)} total)" + ) + + +def test_roll0_online_holds_not_collapses(rolling_uc): + """Pinned driven witness: roll 0's realized ``v_online`` holds at its + startup floor (``[0.418, 0.274, 0.152, 0.152]``) instead of the HEAD + collapse ``[..., 0.044]`` below the ``startup[0]`` obligation.""" + on = _per_roll_frame(rolling_uc["workdir"], "v_online_linear") + online0 = on[0][UNIT].to_list()[:4] + assert online0 == pytest.approx([0.418, 0.274, 0.152, 0.152], abs=5e-3), ( + f"roll-0 online {online0} — expected the held floor, not a collapse" + ) + # The HEAD collapse was online[3]≈0.044 < startup[0]≈0.152; now it holds. + assert online0[3] >= online0[2] - 1e-6, ( + f"roll-0 online collapsed at step 3 ({online0}) — min_uptime not held" + ) From 326840d49c52c1fba53dbdcf39dfba507078d7c0 Mon Sep 17 00:00:00 2001 From: jkiviluo Date: Thu, 15 Oct 2026 17:12:25 +0300 Subject: [PATCH 87/87] fix(rolling): carry min_uptime/min_downtime across within-period roll boundaries (OC1 carrier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling unit-commitment continuity: a min_uptime / min_downtime obligation opened near a roll's jump-end did not constrain the next roll's early steps — the lookback window saw only within-roll steps, so a unit could violate its authored minimum up/down time across the boundary. Pre-existing in both the GMPL reference and the polars port; follows OC1 Carrier 1 (startup recharge) and MINTIME-1 (within-roll enforcement). Fix (internal/derived — no schema, no migration): the prior rolls' realized committed v_startup / v_shutdown are carried as a trailing sliding window (SolveHandoff.online_history, trimmed per (process, period) to the authored max min-up/down duration so it stays bounded over a long run; each step carries its own duration since the history steps are not in the next roll's dt). The continuation roll rebuilds uptime_carry / downtime_carry from the window (online_mintime_carry_from_source) and injects them — densified to the full constraint domain with 0-fill — on the minimum_uptime RHS (carry_up) / minimum_downtime LHS (carry_dn), so an obligation from roll N constrains roll N+1's early steps. PERIOD-AWARE gate (d == d_back): carry ONLY within a period, never across a period boundary — FlexTool's online dynamics are period-cyclic and investment periods are non-contiguous years. A naive cross-period carry is infeasible even with balance slack (a UC-logic conflict, not a balance one); the period-aware gate is the fix. Byte-parity: deterministic / first-solve / rolling-without-min-up-down all take the unchanged baseline path (carry None). Witness: min_uptime 48->0 violations, a new min_downtime witness binds, composes with Carrier 1. New tests/test_rolling_online_mintime_carry.py (8 passed strict). Also refreshes a stale loaders test mock (_ZeroStartupSource) to implement the core InputSource.parameter method Carrier 2's min-time probe now reads. Full suite 4165 passed. Co-Authored-By: Claude Opus 4.8 --- docs/dev/architecture.md | 17 + flextool/engine_polars/_derived_params.py | 114 ++++++ flextool/engine_polars/_provider_keys.py | 2 + .../engine_polars/_provider_translators.py | 4 + flextool/engine_polars/_solve_handoff.py | 23 ++ flextool/engine_polars/input.py | 223 +++++++++++ flextool/engine_polars/model.py | 57 ++- .../loaders/test_a04_a05_online_ramp.py | 10 +- tests/test_rolling_online_mintime_carry.py | 353 ++++++++++++++++++ 9 files changed, 801 insertions(+), 2 deletions(-) create mode 100644 tests/test_rolling_online_mintime_carry.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 78bb0061b..18176ee42 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -789,11 +789,28 @@ names as before for debug inspection: | `realized_existing` | `p_entity_period_existing_capacity.csv` (existing column) | Cumulative existing capacity history per `(entity, period)`. | | `divest_cumulative` | `p_entity_divested.csv` | Cumulative divest per entity carried forward. | | `roll_end_state` / `upward_roll_end_state` | `p_roll_continue_state.csv` | `v_state` at the end of the realized window; pins the next roll's first timestep. | +| `roll_end_online` | *(in-memory only)* | Committed jump-end `v_online` per online process (OC1 Carrier 1); the next roll's `online__startup`/`online__shutdown` first step sees the prior commitment instead of the within-roll cyclic wrap, so a unit that stays committed is not re-charged a startup across the boundary. | +| `online_history` | *(in-memory only)* | Trailing realized committed `v_startup`/`v_shutdown` window (OC1 Carrier 2); rebuilt into `uptime_carry`/`downtime_carry` at the next roll and injected on the `minimum_uptime` RHS / `minimum_downtime` LHS so an obligation opened in roll N constrains roll N+1's early steps. | | `fix_storage_quantity / price / usage` | `fix_storage_*.csv` | Parent-imposed storage quota at the boundary (any subset of the three metrics — each carries its own `(node, period, step)` index, so a separate timesteps carrier is not needed). | | `cumulative_co2` | `co2_cum_realized_tonnes.csv` | Running CO2 totals across rolls. | | `cumulative_commodity` | `commodity_ladder_cumulative.csv` | Per-tier commodity consumption (ladder pricing). | | `cum_sim_hours` | `ladder_cum_sim_hours.csv` | Running simulated-hour total per period. | +Cross-boundary unit-commitment continuity (OC1 Carriers 1 & 2) is +**hard-enforced** and **period-aware**: a `min_uptime` / `min_downtime` +obligation opened near a roll's jump-end carries into the next roll's early +steps *only within the same period*. FlexTool's online dynamics are +period-cyclic (a period's first step wraps within that period, never to the +prior period), so a commitment obligation must not propagate across a period +boundary — the non-contiguous investment years (e.g. 2020 → 2025) do not +connect, and forcing one across would be a unit-commitment infeasibility no +node penalty slack can absorb. A node with the default `penalty_method` +(`regular`) absorbs any forced-on generation through its downward penalty +(`vq_state_down`, a feasible spill); a hard-balance node (`penalty_method = +off`) can instead become infeasible when a myopically-committed unit's +forced generation cannot be absorbed — that is the model correctly refusing +an un-absorbable commitment, resolved by allowing slack. + `fix_storage_timesteps`, `ed_history_realized_first`, and `edd_history` used to be `SolveHandoff` fields too, populated only by the now-removed `capture_post_solve()` disk-read constructor. They were retired when the diff --git a/flextool/engine_polars/_derived_params.py b/flextool/engine_polars/_derived_params.py index 9a0cc11c2..64b246a5a 100644 --- a/flextool/engine_polars/_derived_params.py +++ b/flextool/engine_polars/_derived_params.py @@ -5628,6 +5628,120 @@ def pdt_downtime_set_from_lookback(downtime_lookback: pl.DataFrame | None .collect()) +def online_mintime_carry_from_source( + source: "InputSource", + dt: pl.DataFrame | None, + p_step_duration: object | None, + history: pl.DataFrame | None, + parameter_name: str, + ) -> pl.DataFrame | None: + """OC1 Carrier 2 — build the cross-roll-boundary min_uptime / + min_downtime carry ``(p, d, t, value)`` for a rolling continuation roll. + + ``parameter_name`` is ``"min_uptime"`` (carries realized ``v_startup``, + injected on the ``minimum_uptime`` RHS) or ``"min_downtime"`` (carries + realized ``v_shutdown``, injected on the ``minimum_downtime`` LHS). + + ``history`` is the prior rolls' realized committed-commitment window + handed off by :class:`SolveHandoff` (``online_history``) — one row per + ``(p, d, t)`` with ``startup`` / ``shutdown`` metric columns and a + ``duration`` column (carried so the lookback walk is self-contained; + the history steps are not in this roll's ``dt``). ``None`` / empty on + the first roll / non-rolling / no-online solves → ``None`` here (the + model's online block then takes its byte-identical baseline path). + + Construction (mirrors the within-roll lookback, extended into history): + + * ``L`` is the AUTHORED ``min_uptime`` / ``min_downtime`` parameter + (hours), NOT a step count inferred from the per-roll lookback — a + short / last roll truncates that window and would silently + undercount the carry. + * Run :func:`_build_lookback_rows` with that authored ``L`` over the + combined, chronologically-ordered timeline ``history ++ roll-dt`` + (each step with its own duration), then keep only rows whose + constrained step ``(d, t)`` is a ROLL step and whose predecessor + ``(d_back, t_back)`` is a HISTORY step, summing the carried metric. + * PERIOD-AWARE gate (``d == d_back``): a carried obligation is kept + ONLY within the same period. FlexTool's online dynamics are + period-cyclic (the predecessor of a period's first step wraps + within that period, never to the prior period), so a min-up/down + obligation must NOT be carried across a period boundary — the + non-contiguous investment years (e.g. 2020 → 2025) do not connect, + and forcing one across would be a unit-commitment infeasibility no + node penalty slack can absorb. + + Returns the ``(p, d, t, value)`` carry frame (Utf8 axes, caller casts + to the model Enum domain), or ``None`` when nothing is carried. + """ + if dt is None or dt.height == 0 or history is None or history.height == 0: + return None + procs = _process_min_time(source, parameter_name) + if not procs: + return None + metric = "startup" if parameter_name == "min_uptime" else "shutdown" + if metric not in history.columns: + return None + + roll_ord = _step_order_with_duration(dt, p_step_duration) + if not roll_ord: + return None + roll_set = {(d, t) for d, t, _ in roll_ord} + + # history step durations + per-process metric values (Utf8 keys) + hist_dur: dict[tuple[str, str], float] = {} + hist_val: dict[tuple[str, str, str], float] = {} + for r in history.iter_rows(named=True): + d = str(r["d"]) + t = str(r["t"]) + try: + dur = float(r["duration"]) + except (TypeError, ValueError): + dur = 1.0 + hist_dur.setdefault((d, t), dur) + try: + val = float(r[metric]) + except (TypeError, ValueError): + val = 0.0 + hist_val[(str(r["p"]), d, t)] = val + hist_set = set(hist_dur) + if not hist_set: + return None + + # combined chronologically-ordered timeline: history steps precede the + # roll steps (strictly earlier (d, t)); (d, t) string order is the + # canonical chronological order (p2020 < p2025, t0001 < t0002). + steps_dur: dict[tuple[str, str], float] = dict(hist_dur) + for d, t, dur in roll_ord: + steps_dur[(d, t)] = dur + ordered = sorted((d, t, dur) for (d, t), dur in steps_dur.items()) + + out_rows: list[tuple[str, str, str, float]] = [] + for proc, min_time in procs: + if min_time <= 0: + continue + lb = _build_lookback_rows([(proc, float(min_time))], ordered) + for (pp, d, t, d_back, t_back) in lb: + if (d, t) not in roll_set: # constrained step in the roll + continue + if (d_back, t_back) not in hist_set: # predecessor in history + continue + if d != d_back: # PERIOD-AWARE: same period only + continue + v = hist_val.get((pp, d_back, t_back), 0.0) + if v != 0.0: + out_rows.append((pp, d, t, v)) + if not out_rows: + return None + carry = (pl.DataFrame(out_rows, schema=["p", "d", "t", "value"], + orient="row") + .group_by(["p", "d", "t"]).agg(pl.col("value").sum()) + .filter(pl.col("value").abs() > 1e-12) + .sort("p", "d", "t")) + if carry.height == 0: + return None + return carry + + # --------------------------------------------------------------------------- # §3.12.1 — process_group_inside_nonSync # --------------------------------------------------------------------------- diff --git a/flextool/engine_polars/_provider_keys.py b/flextool/engine_polars/_provider_keys.py index bdb13fd71..094959a52 100644 --- a/flextool/engine_polars/_provider_keys.py +++ b/flextool/engine_polars/_provider_keys.py @@ -143,6 +143,7 @@ HANDOFF_ROLL_END_STATE = "handoff/roll_end_state" HANDOFF_UPWARD_ROLL_END_STATE = "handoff/upward_roll_end_state" HANDOFF_ROLL_END_ONLINE = "handoff/roll_end_online" +HANDOFF_ONLINE_HISTORY = "handoff/online_history" HANDOFF_CUMULATIVE_CO2 = "handoff/cumulative_co2" HANDOFF_CUMULATIVE_COMMODITY = "handoff/cumulative_commodity" HANDOFF_CUM_SIM_HOURS = "handoff/cum_sim_hours" @@ -168,6 +169,7 @@ OVERRIDE_ROLL_END_STATE = "override/roll_end_state" OVERRIDE_UPWARD_ROLL_END_STATE = "override/upward_roll_end_state" OVERRIDE_ROLL_END_ONLINE = "override/roll_end_online" +OVERRIDE_ONLINE_HISTORY = "override/online_history" OVERRIDE_CUMULATIVE_CO2 = "override/cumulative_co2" OVERRIDE_CUMULATIVE_COMMODITY = "override/cumulative_commodity" OVERRIDE_CUM_SIM_HOURS = "override/cum_sim_hours" diff --git a/flextool/engine_polars/_provider_translators.py b/flextool/engine_polars/_provider_translators.py index 07aa3acc3..28daf1c79 100644 --- a/flextool/engine_polars/_provider_translators.py +++ b/flextool/engine_polars/_provider_translators.py @@ -39,6 +39,8 @@ K.HANDOFF_ROLL_END_STATE: ("node", "value"), K.HANDOFF_UPWARD_ROLL_END_STATE: ("node", "value"), K.HANDOFF_ROLL_END_ONLINE: ("process", "value"), + K.HANDOFF_ONLINE_HISTORY: ("process", "period", "step", + "startup", "shutdown", "duration"), K.HANDOFF_CUMULATIVE_CO2: ("group", "period", "value"), K.HANDOFF_CUMULATIVE_COMMODITY: ("commodity", "tier", "period", "p_ladder_cum_realized_mwh"), @@ -79,6 +81,7 @@ def translate_handoff_to_provider( ("roll_end_state", K.HANDOFF_ROLL_END_STATE), ("upward_roll_end_state", K.HANDOFF_UPWARD_ROLL_END_STATE), ("roll_end_online", K.HANDOFF_ROLL_END_ONLINE), + ("online_history", K.HANDOFF_ONLINE_HISTORY), ("cumulative_co2", K.HANDOFF_CUMULATIVE_CO2), ("cumulative_commodity", K.HANDOFF_CUMULATIVE_COMMODITY), ("cum_sim_hours", K.HANDOFF_CUM_SIM_HOURS), @@ -107,6 +110,7 @@ def translate_handoff_to_provider( K.HANDOFF_ROLL_END_STATE: K.OVERRIDE_ROLL_END_STATE, K.HANDOFF_UPWARD_ROLL_END_STATE: K.OVERRIDE_UPWARD_ROLL_END_STATE, K.HANDOFF_ROLL_END_ONLINE: K.OVERRIDE_ROLL_END_ONLINE, + K.HANDOFF_ONLINE_HISTORY: K.OVERRIDE_ONLINE_HISTORY, K.HANDOFF_CUMULATIVE_CO2: K.OVERRIDE_CUMULATIVE_CO2, K.HANDOFF_CUMULATIVE_COMMODITY: K.OVERRIDE_CUMULATIVE_COMMODITY, K.HANDOFF_CUM_SIM_HOURS: K.OVERRIDE_CUM_SIM_HOURS, diff --git a/flextool/engine_polars/_solve_handoff.py b/flextool/engine_polars/_solve_handoff.py index dc4eee8b2..5602974a8 100644 --- a/flextool/engine_polars/_solve_handoff.py +++ b/flextool/engine_polars/_solve_handoff.py @@ -27,6 +27,7 @@ divest_cumulative [entity, value] roll_end_state [node, value] roll_end_online [process, value] + online_history [process, period, step, startup, shutdown, duration] fix_storage_quantity [node, period, step, p_fix_storage_quantity] fix_storage_price [node, period, step, p_fix_storage_price] fix_storage_usage [node, period, step, p_fix_storage_usage] @@ -131,6 +132,27 @@ class SolveHandoff: # Schema: ``[process, value]``. No file equivalent (in-memory only). roll_end_online: pl.DataFrame | None = None + # OC1 Carrier 2 — trailing realized committed-commitment window for + # cross-roll-boundary min_uptime / min_downtime continuity. Unlike + # ``roll_end_online`` (a single jump-end ``v_online`` scalar per + # process), this carries a SLIDING WINDOW of the prior rolls' realized + # committed ``v_startup`` / ``v_shutdown`` per ``(process, period, + # step)``, long enough (trimmed to the authored max min-up/down + # duration per period) to cover the next roll's early-step lookback. + # The next roll rebuilds ``uptime_carry`` / ``downtime_carry`` from it + # (``_derived_params.online_mintime_carry_from_source``) and injects + # them on the ``minimum_uptime`` RHS / ``minimum_downtime`` LHS so an + # obligation opened in roll N constrains roll N+1's early steps. + # PERIOD-AWARE: carried only within the same period (period-cyclic + # online dynamics — non-contiguous investment years do not connect). + # Each step carries its own ``duration`` (the history steps are not in + # the next roll's ``dt``, so the lookback walk must be self-contained). + # Producer: this roll's realized v_startup/v_shutdown ∪ prior window. + # Consumer: next roll's ``uptime_carry`` / ``downtime_carry``. + # Schema: ``[process, period, step, startup, shutdown, duration]``. + # No file equivalent (in-memory only). + online_history: pl.DataFrame | None = None + # Storage quota imposed by parent solve on child solve — narrow # per-metric carriers in canonical column schema. The trio is # independent (parent may set quantity-only, price-only, or any @@ -159,6 +181,7 @@ class SolveHandoff: _FIELDS = ( "realized_invest", "realized_existing", "divest_cumulative", "roll_end_state", "upward_roll_end_state", "roll_end_online", + "online_history", "fix_storage_quantity", "fix_storage_price", "fix_storage_usage", "cumulative_co2", "cumulative_commodity", "cum_sim_hours", ) diff --git a/flextool/engine_polars/input.py b/flextool/engine_polars/input.py index e042ba71c..23ab01124 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -658,6 +658,21 @@ class FlexData: # a startup across the roll boundary. ``None`` on the first roll / # non-rolling / no-online solves (→ byte-identical baseline path). p_roll_continue_online: Param | None = None # (p,) + # OC1 Carrier 2 — cross-roll-boundary min_uptime / min_downtime carry. + # ``online_startup_history`` is the prior rolls' realized committed + # ``v_startup`` / ``v_shutdown`` window (read from the ``online_history`` + # handoff); ``online_min_time`` is the authored max(min_uptime, + # min_downtime) per online process (hours) used only to trim that + # window in the next handoff. ``uptime_carry`` / ``downtime_carry`` are + # the per-``(p, d, t)`` carried obligations built from the window at the + # continuation roll (period-aware) and injected on the ``minimum_uptime`` + # RHS / ``minimum_downtime`` LHS by ``_add_online_block``. All ``None`` + # on the first roll / non-rolling / no-min-up-down solves (→ the online + # block takes its byte-identical baseline path). + online_startup_history: pl.DataFrame | None = None # (p, d, t, startup, shutdown, duration) + online_min_time: pl.DataFrame | None = None # (p, value) — max authored min-up/down hours + uptime_carry: pl.DataFrame | None = None # (p, d, t, value) — carried Σ v_startup + downtime_carry: pl.DataFrame | None = None # (p, d, t, value) — carried Σ v_shutdown # ─── Storage ───────────────────────────────────────────────────────── nodeState: pl.DataFrame | None = None @@ -2275,6 +2290,8 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, pdt_uptime_set=None, pdt_downtime_set=None, uptime_lookback=None, downtime_lookback=None, p_roll_continue_online=None, + online_startup_history=None, online_min_time=None, + uptime_carry=None, downtime_carry=None, ) if pss is None: return blank @@ -2398,6 +2415,49 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, if df_rco.height > 0: p_roll_continue_online = Param(("p",), df_rco) + # OC1 Carrier 2 — read the prior rolls' realized committed-commitment + # window (``handoff/online_history``) and the authored min-up/down + # durations. The window feeds ``uptime_carry`` / ``downtime_carry`` + # (built later on the synthetic/derived path once ``dt`` + the + # lookback are in place); ``online_min_time`` is used only to trim the + # window when THIS roll's handoff is built. Both empty (→ ``None``) on + # the first roll / non-rolling / no-min-up-down solves. + online_startup_history = None + df_hist = read_handoff_frame(provider, K.HANDOFF_ONLINE_HISTORY) + if df_hist is not None and df_hist.height > 0: + df_hist = (df_hist + .pipe(rename_to_axis, + {"process": "p", "period": "d", "step": "t"}) + .with_columns( + pl.col("p").cast(pl.Utf8, strict=False), + pl.col("d").cast(pl.Utf8, strict=False), + pl.col("t").cast(pl.Utf8, strict=False), + pl.col("startup").cast(pl.Float64, strict=False) + .fill_null(0.0), + pl.col("shutdown").cast(pl.Float64, strict=False) + .fill_null(0.0), + pl.col("duration").cast(pl.Float64, strict=False) + .fill_null(1.0)) + .select("p", "d", "t", "startup", "shutdown", "duration")) + if df_hist.height > 0: + online_startup_history = df_hist + + # Authored max(min_uptime, min_downtime) per online process (hours) — + # used only to trim ``online_history`` in the next handoff. Solve- + # agnostic; ``None`` when no process authors min-up/down. + online_min_time = None + if source is not None: + from flextool.engine_polars import _derived_params as _drv_mt + _mt: dict[str, float] = {} + for _pn in ("min_uptime", "min_downtime"): + for _p, _v in _drv_mt._process_min_time(source, _pn): + if _v > _mt.get(_p, 0.0): + _mt[_p] = _v + if _mt: + online_min_time = pl.DataFrame( + {"p": list(_mt.keys()), + "value": [float(v) for v in _mt.values()]}) + return dict( process_online=p_online, process_online_linear=p_online_lin, @@ -2415,6 +2475,10 @@ def _load_online(inp: Path, sd: Path, dt: pl.DataFrame, uptime_lookback=None, downtime_lookback=None, p_roll_continue_online=p_roll_continue_online, + online_startup_history=online_startup_history, + online_min_time=online_min_time, + uptime_carry=None, + downtime_carry=None, ) @@ -5200,6 +5264,23 @@ def _wire_mintime_for_synthetic_solve(): flex_data.downtime_lookback = dlb flex_data.pdt_downtime_set = ( _drv.pdt_downtime_set_from_lookback(dlb)) + # OC1 Carrier 2 — cross-roll-boundary min-up/down continuity. + # Build ``uptime_carry`` / ``downtime_carry`` from the prior + # rolls' realized committed-commitment window + # (``online_startup_history``, read from the handoff by + # ``_load_online``) using the AUTHORED min-up/down parameter as + # the window length and the PERIOD-AWARE gate (same-period + # carry only — period-cyclic online dynamics). ``None`` on the + # first roll / non-rolling / no-min-up-down solves (empty + # history) → the online block takes its baseline path. + hist = getattr(flex_data, "online_startup_history", None) + if hist is not None and hist.height > 0: + flex_data.uptime_carry = ( + _drv.online_mintime_carry_from_source( + db_reader, dt, sd, hist, "min_uptime")) + flex_data.downtime_carry = ( + _drv.online_mintime_carry_from_source( + db_reader, dt, sd, hist, "min_downtime")) _loadflex_prof("apply_db_overrides:pass_synthetic_mintime") _timed("c.synth mintime", _wire_mintime_for_synthetic_solve) return @@ -6213,6 +6294,147 @@ def build_handoff_from_solution( roll_end_online_df = pl.DataFrame( rco_rows, schema=["process", "value"], orient="row") + # ---- online_history: trailing realized committed v_startup/v_shutdown ---- + # OC1 Carrier 2 — the sliding-window producer (roll_end_online above + # carries only the single jump-end ``v_online``; this carries the + # trailing window of realized committed ``v_startup`` / ``v_shutdown`` + # per (process, period, step) so the next roll can honour a min_uptime / + # min_downtime obligation opened near the prior roll's jump-end). + # Accumulates ``prior_handoff.online_history`` ∪ this roll's realized + # steps, trimmed per (process, period) to the authored max min-up/down + # duration (``flex_data.online_min_time``) so it stays bounded over a + # long run. Each step carries its own ``duration`` (the next roll's + # ``dt`` does not contain these steps). Restricted to processes that + # author min-up/down (inner-joined on ``online_min_time``) — a process + # with no obligation never carries, so it is never stored. + online_history_df = None + _has_su_sd = any( + v in sol._vars for v in + ("v_startup_linear", "v_startup_integer", + "v_shutdown_linear", "v_shutdown_integer")) + online_min_time_df = (getattr(flex_data, "online_min_time", None) + if flex_data is not None else None) + # Only produce the window for processes that author min-up/down + # (``online_min_time`` non-empty); a solve with no min-up/down never + # carries, so nothing is stored — byte-identical to the baseline. + if (last_pairs_on is not None and last_pairs_on.height > 0 + and _has_su_sd + and online_min_time_df is not None + and online_min_time_df.height > 0): + _cols = last_pairs_on.columns + _dc = "period" if "period" in _cols else "d" + _tc = "step" if "step" in _cols else "t" + realized_dt = (last_pairs_on + .select(alias_to_axis(_dc, "d"), alias_to_axis(_tc, "t")) + .unique()) + + def _pull_online_metric(vnames): + frames = [] + for vn in vnames: + if vn not in sol._vars: + continue + _v = sol._vars[vn] + _fr = _v.frame.join(realized_dt, on=["d", "t"], how="inner") + if _fr.height == 0: + continue + _ids = _fr["col_id"].to_numpy() + frames.append(_fr.select("p", "d", "t").with_columns( + value=pl.Series(sol.col_value[_ids]))) + if not frames: + return None + return (pl.concat(frames).group_by(["p", "d", "t"]) + .agg(pl.col("value").sum())) + + su = _pull_online_metric(("v_startup_linear", "v_startup_integer")) + sh = _pull_online_metric(("v_shutdown_linear", "v_shutdown_integer")) + if su is not None or sh is not None: + keys = (pl.concat([f.select("p", "d", "t") + for f in (su, sh) if f is not None]) + .unique()) + hist_now = keys + if su is not None: + hist_now = hist_now.join( + su.rename({"value": "startup"}), on=["p", "d", "t"], + how="left") + else: + hist_now = hist_now.with_columns(startup=pl.lit(0.0)) + if sh is not None: + hist_now = hist_now.join( + sh.rename({"value": "shutdown"}), on=["p", "d", "t"], + how="left") + else: + hist_now = hist_now.with_columns(shutdown=pl.lit(0.0)) + hist_now = hist_now.with_columns( + pl.col("p").cast(pl.Utf8), pl.col("d").cast(pl.Utf8), + pl.col("t").cast(pl.Utf8), + pl.col("startup").cast(pl.Float64).fill_null(0.0), + pl.col("shutdown").cast(pl.Float64).fill_null(0.0)) + # per-(d, t) durations + _sdf = _step_duration_frame(sd, flex_data, provider=provider) + if _sdf is not None and _sdf.height > 0: + _d2 = "period" if "period" in _sdf.columns else ( + "d" if "d" in _sdf.columns else None) + _t2 = "time" if "time" in _sdf.columns else ( + "step" if "step" in _sdf.columns else ( + "t" if "t" in _sdf.columns else None)) + _v2 = "value" if "value" in _sdf.columns else ( + "p_step_duration" if "p_step_duration" in _sdf.columns + else None) + if _d2 and _t2 and _v2: + dur = (_sdf.select( + pl.col(_d2).cast(pl.Utf8).alias("d"), + pl.col(_t2).cast(pl.Utf8).alias("t"), + pl.col(_v2).cast(pl.Float64).alias("duration")) + .unique(subset=["d", "t"])) + hist_now = hist_now.join(dur, on=["d", "t"], how="left") + if "duration" not in hist_now.columns: + hist_now = hist_now.with_columns(duration=pl.lit(1.0)) + hist_now = hist_now.with_columns( + pl.col("duration").cast(pl.Float64).fill_null(1.0)) + + # accumulate prior window (rename carrier schema → p/d/t) + prior_hist = (prior_handoff.online_history + if prior_handoff is not None else None) + if prior_hist is not None and prior_hist.height > 0: + prior_renamed = prior_hist.rename( + {"process": "p", "period": "d", "step": "t"}).with_columns( + pl.col("p").cast(pl.Utf8), pl.col("d").cast(pl.Utf8), + pl.col("t").cast(pl.Utf8), + pl.col("startup").cast(pl.Float64), + pl.col("shutdown").cast(pl.Float64), + pl.col("duration").cast(pl.Float64)) + combined = (pl.concat( + [hist_now.select("p", "d", "t", "startup", "shutdown", + "duration"), + prior_renamed.select("p", "d", "t", "startup", "shutdown", + "duration")]) + .unique(subset=["p", "d", "t"], keep="first")) + else: + combined = hist_now.select( + "p", "d", "t", "startup", "shutdown", "duration") + + # trim per (process, period) to the authored min-up/down window + if online_min_time_df is not None and online_min_time_df.height > 0: + lmap = online_min_time_df.select( + pl.col("p").cast(pl.Utf8), + pl.col("value").cast(pl.Float64).alias("_L")) + combined = combined.join(lmap, on="p", how="inner") + if combined.height > 0: + combined = (combined.sort(["p", "d", "t"]) + .with_columns( + _rev=pl.col("duration").reverse() + .cum_sum().reverse().over(["p", "d"])) + # keep steps whose durations strictly after + # them sum to < L (covers the next roll's + # first-step lookback window, plus one). + .filter(pl.col("_rev") - pl.col("duration") + < pl.col("_L")) + .drop(["_L", "_rev"])) + if combined.height > 0: + online_history_df = combined.rename( + {"p": "process", "d": "period", "t": "step"}).sort( + ["process", "period", "step"]) + # ---- fix_storage_quantity: v_state at fix_quantity timesteps × unitsize ---- # Mirrors flextool's ``write_fix_storage_quantity`` (handoff_writers.py # :380). Restricted to nodes whose storage_nested_fix_method is @@ -6828,6 +7050,7 @@ def build_handoff_from_solution( roll_end_state=roll_end_state_df, upward_roll_end_state=upward_roll_end_state_df, roll_end_online=roll_end_online_df, + online_history=online_history_df, fix_storage_quantity=fix_storage_quantity_df, fix_storage_price=fix_storage_price_df, fix_storage_usage=fix_storage_usage_df, diff --git a/flextool/engine_polars/model.py b/flextool/engine_polars/model.py index 6670d1bfe..28090fadf 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -5120,6 +5120,37 @@ def _add_max_output_sum(m, d, v_flow, pss_dt: "pl.DataFrame | None", *, lhs_terms=lhs, rhs_terms={"upper": rhs}) +def _online_mintime_carry_term(carry: "pl.DataFrame | None", + idx: "pl.DataFrame") -> "Param | None": + """OC1 Carrier 2 — densify a cross-roll-boundary min-up/down carry frame + ``(p, d, t, value)`` (Utf8 axes, from + ``online_mintime_carry_from_source``) to the full constraint domain + ``idx`` with explicit 0-fill and wrap it as a ``Param((p, d, t))``. + + Returns ``None`` when there is no carry (first roll / non-rolling / + no-min-up-down) so the caller omits the term entirely (byte-identical + baseline). The DENSE 0-fill is required: a restricted-domain ``(p, d, + t)`` term misaligns the ``minimum_downtime`` RHS ``existing`` ((p, d) + arity) and makes the row infeasible even at 0.0 — densifying to the + constraint domain keeps every row's constant well-defined. The carry's + String p/d/t are cast to ``idx``'s (Enum) axis dtypes before the join; + a cross-dtype join raises ``SchemaError``. The left-join against + ``idx`` also restricts the carry to this block's processes (``idx`` is + already intersected with this kind's ``online_set``).""" + if carry is None or carry.height == 0: + return None + carry_cast = carry.with_columns( + pl.col("p").cast(idx.schema["p"], strict=False), + pl.col("d").cast(idx.schema["d"], strict=False), + pl.col("t").cast(idx.schema["t"], strict=False)) + dense = (idx.select("p", "d", "t") + .join(carry_cast, on=["p", "d", "t"], how="left") + .with_columns(pl.col("value").fill_null(0.0))) + if dense.height == 0: + return None + return Param(("p", "d", "t"), dense) + + def _add_online_block(m, d, v_flow, kind: str, p_idx: "pl.DataFrame", online_set: "pl.DataFrame", v_online, v_startup, v_shutdown, @@ -5358,10 +5389,22 @@ def _lhs_with_invdiv(var, key): ) startup_sum = Sum(Where(v_startup_at, lkb), over=("d_back", "t_back")) + up_rhs = {"startup_sum": startup_sum} + # OC1 Carrier 2 — cross-roll-boundary min_uptime carry. On a + # continuation roll ``d.uptime_carry`` holds the Σ of the + # prior roll's realized ``v_startup`` that falls within this + # roll's early-step lookback window (period-aware; built by + # ``online_mintime_carry_from_source``). Densify to the + # full ``up_idx`` domain with explicit 0-fill and inject on + # the RHS so ``v_online ≥ Σ v_startup_in_roll + carry_up``. + # ``None`` (first roll / non-rolling / no-carry) → baseline. + up_carry = _online_mintime_carry_term(d.uptime_carry, up_idx) + if up_carry is not None: + up_rhs["carry_up"] = up_carry m.add_cstr(f"minimum_uptime{sfx}", over=up_idx, sense=">=", lhs_terms={"online": v_online}, - rhs_terms={"startup_sum": startup_sum}) + rhs_terms=up_rhs) # minimum_downtime: existing_count - v_online >= Σ v_shutdown[lookback] # Rewritten LP-friendly: v_online + Σ v_shutdown ≤ existing_count. @@ -5397,6 +5440,18 @@ def _lhs_with_invdiv(var, key): lhs_dn["invest_neg"] = invest_term if divest_term is not None: lhs_dn["divest"] = divest_term + # OC1 Carrier 2 — cross-roll-boundary min_downtime carry. + # ``d.downtime_carry`` holds the Σ of the prior roll's + # realized ``v_shutdown`` within this roll's early-step + # lookback window (period-aware). Densify to the full + # ``dn_idx`` domain with explicit 0-fill (a restricted- + # domain (p,d,t) term would misalign the ``existing`` + # (p,d)-arity RHS and make the row infeasible even at 0.0) + # and inject on the LHS so ``v_online + Σ v_shutdown_in_roll + # + carry_dn ≤ existing_count``. + dn_carry = _online_mintime_carry_term(d.downtime_carry, dn_idx) + if dn_carry is not None: + lhs_dn["carry_dn"] = dn_carry m.add_cstr(f"minimum_downtime{sfx}", over=dn_idx, sense="<=", lhs_terms=lhs_dn, diff --git a/tests/engine_polars/loaders/test_a04_a05_online_ramp.py b/tests/engine_polars/loaders/test_a04_a05_online_ramp.py index ecc6ccb5c..8d85c00a3 100644 --- a/tests/engine_polars/loaders/test_a04_a05_online_ramp.py +++ b/tests/engine_polars/loaders/test_a04_a05_online_ramp.py @@ -171,7 +171,12 @@ def test_pdt_online_zero_startup_cost_excluded(online_workdir): dt = pl.DataFrame({"d": ["p2020"], "t": ["t0001"]}) class _ZeroStartupSource: - def parameter_explicit(self, ec: str, name: str) -> pl.DataFrame: + # ``parameter`` is the core InputSource read; ``parameter_explicit`` + # the optional refinement. Both expose only the lone zero-startup + # row — every other ``(ec, name)`` raises ``KeyError`` so callers + # (e.g. Carrier 2's ``_process_min_time`` probe for min_uptime / + # min_downtime) see "no such parameter" and contribute nothing. + def parameter(self, ec: str, name: str) -> pl.DataFrame: if (ec, name) == ("unit", "startup_cost"): # Hand-calc: explicit zero row triggers filter(value != 0). return pl.DataFrame({"name": ["coal_plant"], @@ -179,6 +184,9 @@ def parameter_explicit(self, ec: str, name: str) -> pl.DataFrame: "value": [0.0]}) raise KeyError((ec, name)) + def parameter_explicit(self, ec: str, name: str) -> pl.DataFrame: + return self.parameter(ec, name) + out = _load_online(inp, sd, dt, pss, source=_ZeroStartupSource()) # Hand-calc: filter removes the only sc row -> sc_frame.height == 0 # -> pdt_online_lin / p_startup_cost left at their initial None. diff --git a/tests/test_rolling_online_mintime_carry.py b/tests/test_rolling_online_mintime_carry.py new file mode 100644 index 000000000..d9f9abe43 --- /dev/null +++ b/tests/test_rolling_online_mintime_carry.py @@ -0,0 +1,353 @@ +"""OC1 Carrier 2 — cross-roll-boundary min_uptime / min_downtime continuity. + +MINTIME-1 (the prerequisite slice) restored WITHIN-roll min-up/down +enforcement on the rolling/synthetic path. Carrier 2 carries an obligation +opened in roll N into roll N+1's early steps: the prior roll's realized +committed ``v_startup`` / ``v_shutdown`` is handed off (``online_history``), +rebuilt into ``uptime_carry`` / ``downtime_carry`` at the continuation roll +(``_derived_params.online_mintime_carry_from_source``) and injected on the +``minimum_uptime`` RHS / ``minimum_downtime`` LHS by ``_add_online_block``. + +The carry is PERIOD-AWARE: an obligation is carried ONLY within the same +period, never across a period boundary. FlexTool's online dynamics are +period-cyclic (a period's first step wraps within that period, never to the +prior period), so a min-up/down obligation must not propagate across the +non-contiguous investment years — forcing one across is a unit-commitment +infeasibility no node penalty slack can absorb (STEP-0 finding). + +All scenarios are built from JSON (CLAUDE.md invariant #3), never a +checked-in .sqlite. The witnesses drive the full rolling chain and read +the realized ``output_raw`` parquets. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import polars as pl +import pytest + +TEST_DIR = Path(__file__).parent +if str(TEST_DIR) not in sys.path: + sys.path.insert(0, str(TEST_DIR)) + +from db_utils import json_to_db # noqa: E402 + +from flextool.engine_polars import run_chain_from_db # noqa: E402 +from flextool.update_flextool.db_migration import migrate_database # noqa: E402 + +pytestmark = [pytest.mark.solver] + +UNIT = "coal_plant" +EXISTING_COUNT = 1.0 # existing=500, unitsize=500 → v_online ∈ [0, 1] + + +# ───────────────────────── helpers ────────────────────────────────────── +def _roll_index(name: str) -> int: + return int(name.split("roll_")[-1]) + + +def _per_roll_frame(workdir: Path, key: str) -> dict[int, pl.DataFrame]: + out: dict[int, pl.DataFrame] = {} + for f in (workdir / "output_raw").glob(f"{key}__*.parquet"): + out[_roll_index(f.stem)] = pl.read_parquet(f).sort(["period", "time"]) + return out + + +def _global_realized(workdir: Path): + """Concatenate the per-roll realized v_online / v_startup / v_shutdown in + roll order, keeping each row's (period, time, roll-source).""" + on = _per_roll_frame(workdir, "v_online_linear") + su = _per_roll_frame(workdir, "v_startup_linear") + sd = _per_roll_frame(workdir, "v_shutdown_linear") + rows = [] + for r in sorted(on): + o = on[r][UNIT].to_list() + s = su[r][UNIT].to_list() + d = sd[r][UNIT].to_list() + per = on[r]["period"].to_list() + tm = on[r]["time"].to_list() + for k in range(len(o)): + rows.append((r, per[k], tm[k], o[k], s[k], d[k])) + return rows + + +def _cross_boundary_violations(rows, win_len, leg): + """Count cross-roll-boundary, same-period min-up/down violations. + + leg='up': v_online[k] < Σ v_startup over the same-period window ending at + k (window straddling a roll boundary). + leg='dn': v_online[k] + Σ v_shutdown over that window > existing. + """ + viol = [] + for k in range(len(rows)): + _, per_k, tm_k, o_k, _, _ = rows[k] + lo = max(0, k - win_len + 1) + win = [j for j in range(lo, k + 1) if rows[j][1] == per_k] + if not win: + continue + straddles = rows[win[0]][0] != rows[k][0] # different roll source + if not straddles: + continue + if leg == "up": + ssum = sum(rows[j][4] for j in win) + if o_k < ssum - 1e-5: + viol.append((per_k, tm_k, round(o_k, 3), round(ssum, 3))) + else: + dsum = sum(rows[j][5] for j in win) + if o_k + dsum > EXISTING_COUNT + 1e-5: + viol.append((per_k, tm_k, round(o_k, 3), round(dsum, 3))) + return viol + + +def _db(tmp_path_factory, name): + db_path = tmp_path_factory.mktemp(name) / "tests.sqlite" + url = json_to_db(TEST_DIR / "fixtures" / "tests.json", db_path) + migrate_database(url, up_to=40) + return url + + +# ───────────────────── min_uptime witness (48 → 0) ────────────────────── +@pytest.fixture(scope="module") +def updown_run(tmp_path_factory): + """``coal_min_updown_roll``: min_uptime = min_downtime = 5, method=both, + on the fullYear rolling timeline (horizon=8, jump=4). Exercises BOTH + Carrier 1 (``roll_end_online``, inherent to rolling UC) and Carrier 2.""" + from spinedb_api import DatabaseMapping, import_data + url = _db(tmp_path_factory, "db_c2_updown") + scen = "coal_min_updown_roll" + with DatabaseMapping(url) as dm: + import_data(dm, scenarios=[(scen, False, "")], scenario_alternatives=[ + (scen, "init", "west"), (scen, "west", "coal"), + (scen, "coal", "coal_min_load"), + (scen, "coal_min_load", "coal_min_updowntime"), + (scen, "coal_min_updowntime", "wind"), (scen, "wind", "fullYear"), + (scen, "fullYear", "dispatch_fullYear_roll"), + (scen, "dispatch_fullYear_roll", None)]) + dm.commit_session("c2 updown witness") + workdir = tmp_path_factory.mktemp("c2_updown_run") + steps = run_chain_from_db(url, scen, work_folder=workdir, + keep_solutions=True) + return {"steps": steps, "workdir": workdir} + + +def test_min_uptime_cross_boundary_zero(updown_run): + """Carrier 2: cross-boundary min_uptime obligations are honoured — 0 + cross-roll-boundary up-violations (HEAD/MINTIME-1 left 48).""" + assert all(s.optimal for s in updown_run["steps"].values()) + rows = _global_realized(updown_run["workdir"]) + viol = _cross_boundary_violations(rows, 5, "up") + assert not viol, f"{len(viol)} cross-boundary min_uptime violations: {viol[:5]}" + + +def test_composition_carrier1_and_carrier2_active(updown_run): + """Composition: on a continuation roll BOTH carriers are live — Carrier 1 + (``p_roll_continue_online``, no startup double-count) and Carrier 2 + (``uptime_carry`` / ``online_startup_history``). Both obligations are + honoured (0 cross-boundary up+down violations) and every roll is + optimal.""" + steps = updown_run["steps"] + assert all(s.optimal for s in steps.values()) + c1 = c2 = False + for step in steps.values(): + fd = step.flex_data + if getattr(fd, "p_roll_continue_online", None) is not None: + c1 = True + if getattr(fd, "uptime_carry", None) is not None or \ + getattr(fd, "online_startup_history", None) is not None: + c2 = True + assert c1, "Carrier 1 (p_roll_continue_online) never fired on any roll" + assert c2, "Carrier 2 (uptime_carry/online_startup_history) never fired" + rows = _global_realized(updown_run["workdir"]) + assert not _cross_boundary_violations(rows, 5, "up") + assert not _cross_boundary_violations(rows, 5, "dn") + + +# ──────────── period-aware: within-period carries, across does not ─────── +@pytest.fixture(scope="module") +def cross_period_run(tmp_path_factory): + """min_uptime=6 (> jump), method=min_uptime. The fullYear rolling + timeline has a roll straddling the p2020→p2025 period boundary; a NAIVE + cross-period carry makes that roll infeasible even with node slack + (STEP-0 finding). The period-aware carry keeps it feasible.""" + from spinedb_api import DatabaseMapping, import_data + url = _db(tmp_path_factory, "db_c2_xp") + scen = "coal_min_up6_roll" + with DatabaseMapping(url) as dm: + import_data(dm, scenarios=[(scen, False, "")], scenario_alternatives=[ + (scen, "init", "west"), (scen, "west", "coal"), + (scen, "coal", "coal_min_load"), + (scen, "coal_min_load", "coal_min_updowntime"), + (scen, "coal_min_updowntime", "wind"), (scen, "wind", "fullYear"), + (scen, "fullYear", "dispatch_fullYear_roll"), + (scen, "dispatch_fullYear_roll", None)]) + import_data(dm, object_parameter_values=[ + ("unit", UNIT, "min_uptime", 6.0, "coal_min_updowntime"), + ("unit", UNIT, "min_downtime", 0.0, "coal_min_updowntime"), + ("unit", UNIT, "minimum_time_method", "min_uptime", + "coal_min_updowntime")]) + dm.commit_session("c2 cross-period witness") + workdir = tmp_path_factory.mktemp("c2_xp_run") + steps = run_chain_from_db(url, scen, work_folder=workdir, + keep_solutions=True) + return {"steps": steps, "workdir": workdir} + + +def test_period_aware_cross_period_feasible(cross_period_run): + """A min_uptime=6 roll straddling the period boundary stays optimal — the + period-aware carry does NOT force a cross-period obligation (which would + be infeasible). HEAD with a naive cross-period carry is infeasible.""" + steps = cross_period_run["steps"] + nonopt = [n for n, s in steps.items() if not s.optimal] + assert not nonopt, f"cross-period rolls not optimal: {nonopt[:5]}" + + +def test_period_aware_within_period_carries_across_does_not(cross_period_run): + """Explicit period-aware behaviour: on the roll whose window first enters + a NEW period, the carry holds NO obligation for that new period's early + steps (no history there yet / no cross-period carry), while within-period + continuation rolls DO carry (uptime_carry non-empty with all rows in a + single period).""" + within_period_carried = False + for step in cross_period_run["steps"].values(): + uc = getattr(step.flex_data, "uptime_carry", None) + if uc is None or uc.height == 0: + continue + carried_periods = set(uc["d"].to_list()) + # every carried row's constrained step shares the period of its + # carried predecessor — never a cross-period obligation + within_period_carried = True + # the roll's dt periods + dt = step.flex_data.dt + roll_periods = set(dt["d"].cast(pl.Utf8).to_list()) + # the first period a roll ever visits has no in-period history → if a + # roll spans two periods, the carry must not reference the later + # period's opening step from the earlier period's history. Assert + # each carried obligation lies in a period that the roll actually + # realizes with prior same-period history — operationally: the carry + # period set is a subset of the roll's periods (never invents one). + assert carried_periods <= roll_periods, ( + f"carry references a period not in the roll: " + f"{carried_periods - roll_periods}") + assert within_period_carried, ( + "no within-period carry ever fired — witness does not exercise " + "Carrier 2") + + +# ─────────────────── dedicated min_downtime witness ───────────────────── +@pytest.fixture(scope="module") +def downtime_run(tmp_path_factory): + """Dedicated min_downtime binding witness. Demand is high (−400) except + a long valley (0) at t0007–t0020 so the shutdown roll (horizon 8) cannot + see the far-future peak and cleanly FULLY shuts coal at t0007; demand + resumes at t0021 — still within min_downtime=20 of that shutdown. Wind + is removed so coal is the swing unit. WITHOUT the carry a later roll + illegally re-starts coal at t0021; WITH it the obligation is honoured + (``v_online + carried_shutdown ≤ existing`` ⇒ v_online ≤ ~0.2).""" + from spinedb_api import DatabaseMapping, import_data + from spinedb_api.parameter_value import Map + url = _db(tmp_path_factory, "db_c2_dn") + scen = "coal_min_dn_roll" + steps_t = [f"t{i:04d}" for i in range(1, 73)] + dem = [(s, 0.0 if 7 <= i <= 20 else -400.0) + for i, s in enumerate(steps_t, start=1)] + demand_map = Map([s for s, _ in dem], [v for _, v in dem], + index_name="time") + with DatabaseMapping(url) as dm: + import_data(dm, alternatives=["dn_witness"], + scenarios=[(scen, False, "")], scenario_alternatives=[ + (scen, "init", "west"), (scen, "west", "coal"), + (scen, "coal", "coal_min_load"), + (scen, "coal_min_load", "coal_min_updowntime"), + (scen, "coal_min_updowntime", "wind"), (scen, "wind", "fullYear"), + (scen, "fullYear", "dispatch_fullYear_roll"), + (scen, "dispatch_fullYear_roll", "dn_witness"), + (scen, "dn_witness", None)]) + import_data(dm, object_parameter_values=[ + ("unit", UNIT, "min_uptime", 0.0, "dn_witness"), + ("unit", UNIT, "min_downtime", 20.0, "dn_witness"), + ("unit", UNIT, "minimum_time_method", "min_downtime", + "dn_witness"), + ("unit", "wind_plant", "existing", 0.0, "dn_witness"), + ("node", "west", "inflow", demand_map, "dn_witness")]) + dm.commit_session("c2 min_downtime witness") + workdir = tmp_path_factory.mktemp("c2_dn_run") + steps = run_chain_from_db(url, scen, work_folder=workdir, + keep_solutions=True) + return {"steps": steps, "workdir": workdir} + + +def _online_at(rows, period, time): + for (_, per, tm, o, _, _) in rows: + if per == period and tm == time: + return o + raise AssertionError(f"({period},{time}) not realized") + + +def test_min_downtime_shutdown_realized(downtime_run): + """Coal fully shuts at t0007 (the valley opens) — v_online 0.8 → 0 with a + full v_shutdown — giving a genuine obligation to carry.""" + assert all(s.optimal for s in downtime_run["steps"].values()) + rows = _global_realized(downtime_run["workdir"]) + assert _online_at(rows, "p2020", "t0006") > 0.5 + assert _online_at(rows, "p2020", "t0007") < 0.05, ( + "coal did not fully shut at the valley") + + +def test_min_downtime_cross_boundary_binds(downtime_run): + """Carrier 2 (down leg): at the t0021 demand peak — 14 steps after the + t0007 shutdown, within min_downtime=20 — coal is held OFF (v_online ≤ 0.3) + by the carried shutdown. HEAD (no Carrier 2) illegally re-starts coal to + ~0.8 because the continuation roll has forgotten the prior shutdown.""" + rows = _global_realized(downtime_run["workdir"]) + online_peak = _online_at(rows, "p2020", "t0021") + assert online_peak <= 0.3, ( + f"coal online={online_peak} at the post-valley peak — the carried " + f"min_downtime obligation was not honoured (HEAD re-starts to ~0.8)") + + +def test_downtime_carry_is_period_aware_and_present(downtime_run): + """The down-leg carry is actually built and period-aware (every carried + row's period equals its carried predecessor's period, by construction).""" + any_dn_carry = False + for step in downtime_run["steps"].values(): + dc = getattr(step.flex_data, "downtime_carry", None) + if dc is not None and dc.height > 0: + any_dn_carry = True + roll_periods = set(step.flex_data.dt["d"].cast(pl.Utf8).to_list()) + assert set(dc["d"].to_list()) <= roll_periods + assert any_dn_carry, "downtime_carry never fired — witness is inert" + + +# ───────────────────────── byte-parity (no-op) ────────────────────────── +@pytest.fixture(scope="module") +def _no_mintime_rolling(tmp_path_factory): + """A rolling solve WITHOUT any min-up/down. Carrier 2 must be fully + inert: no ``online_history`` stored, no carry built.""" + from spinedb_api import DatabaseMapping, import_data + url = _db(tmp_path_factory, "db_c2_nomin") + scen = "coal_nomin_roll" + with DatabaseMapping(url) as dm: + import_data(dm, scenarios=[(scen, False, "")], scenario_alternatives=[ + (scen, "init", "west"), (scen, "west", "coal"), + (scen, "coal", "coal_min_load"), (scen, "coal_min_load", "wind"), + (scen, "wind", "fullYear"), + (scen, "fullYear", "dispatch_fullYear_roll"), + (scen, "dispatch_fullYear_roll", None)]) + dm.commit_session("c2 no-mintime rolling") + workdir = tmp_path_factory.mktemp("c2_nomin_run") + steps = run_chain_from_db(url, scen, work_folder=workdir, + keep_solutions=True) + return steps + + +def test_no_mintime_rolling_is_inert(_no_mintime_rolling): + """Byte-parity gate: without min-up/down, Carrier 2 stores and builds + nothing on every roll (so the solve takes its baseline path).""" + assert all(s.optimal for s in _no_mintime_rolling.values()) + for step in _no_mintime_rolling.values(): + fd = step.flex_data + assert getattr(fd, "online_startup_history", None) is None + assert getattr(fd, "uptime_carry", None) is None + assert getattr(fd, "downtime_carry", None) is None + assert getattr(fd, "online_min_time", None) is None