diff --git a/CHANGELOG.md b/CHANGELOG.md index 898bd1dc4..a70e856f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ ## Unreleased +Schema change (adds `solve.stochastic_invest_method`, +`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). 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. + +- **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 + 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.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 `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 + 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 + 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`). 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. - **Minimum load now applies once per unit, on the sum of its outputs.** `min_load` × online capacity is a floor on the sum of the outputs that define the unit's capacity (outputs with `conversion_flow_coeff ≠ 0`; the 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/docs/how_to.md b/docs/how_to.md index 5adceb582..4ee3f8a9c 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 (`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] @@ -625,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) @@ -1119,13 +1129,17 @@ 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. 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 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, 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 @@ -1139,27 +1153,48 @@ 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). ![Stocahstic system](./img/concept/stochastic_system.png) +### What the model ties across branches (dispatch vs. investment defaults) + +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 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 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 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. @@ -1167,14 +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. -- 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)** @@ -1183,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`: -For the *solve* parameter `stochastic_branches` this means analysis times (t0001, t0025, t0049, t0073, t0097, t0121 and t0145) +- `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; `separate_dispatch`'s automatic-selection defaults are still being refined. Otherwise there is no difference to the previous example. @@ -1194,6 +1237,34 @@ 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. The stochastic-investment modes below differ in *what is decided before the uncertainty is revealed*. + +#### 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 `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 `shared_invest_periods`. Compose three knobs: + +- `stochastic_invest_method = recourse` (so per-branch investment variables exist to tie); +- `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 `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 + +`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 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 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. diff --git a/docs/reference.md b/docs/reference.md index 5d91712c8..4cdb88ff3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -79,7 +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_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: @@ -530,6 +531,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, per MW of shortfall and hour. Constant. Default 5000 (applied to every reserve group without a value of its own, so a reserve requirement is never free to leave unmet). The unmet part (reserve shortfall) can cover the whole requirement of every method and is always priced with this penalty: for a timeseries requirement the shortfall is at most `reservation`; for a dynamic requirement at most the sum of the participating flows' capacities × `increase_reserve_ratio`; for a large_failure requirement at most the sum of the failing flows' capacities × `large_failure_ratio` (capacity = existing plus the most that can be invested by that period; with unlimited investment `max_flow_for_unconstrained_variables` is used). A group combining methods adds these up. - `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/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) 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/_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/_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 7f5ecf39e..82a4d9c24 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+): @@ -145,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")) @@ -345,6 +369,26 @@ def steps_in_use_lf(workdir: Path | None, .unique()) +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). + """ + 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 + + # --------------------------------------------------------------------------- # pd_branch_weight / pdt_branch_weight — lazy normalisation # --------------------------------------------------------------------------- @@ -401,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]] = {} @@ -416,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}``) @@ -425,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 @@ -490,6 +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)} + # 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]] = {} @@ -505,6 +571,10 @@ 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 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()) denom = 0.0 for row in pb_iter: @@ -513,6 +583,9 @@ def w(b: str) -> float: continue if (d2, d) not in pb_set: continue + if b in phantom: + # Exclude the phantom trunk from the forecast denominator. + continue denom += w(b) if denom == 0.0: # Dense semantics — fall through to 1.0. @@ -532,22 +605,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: @@ -655,6 +758,907 @@ 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[tuple[str, list[str]]]: + """Build the solve's scenario leaves (design §2.2). + + Returns ``(label, members)`` pairs (F4, Slice E design §6.2): the + 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; 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]]] = [("__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] + 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((br, 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 _label, 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 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, + 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 ``"__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 + 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 + ``"__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`` + 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) + # 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 → __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 + # 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}) + 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 _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) — 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 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, + active_solve: str | None = None, + *, + 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. + + 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). + + 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 + ``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")} + 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: + return _empty_lf(schema) + anchors, pos, synthetic, anchor_of = _classify_lineage( + 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 + 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, + 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, tie_periods=tie_periods).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-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 + p = Path(workdir) / "solve_data" / name + df = _provider_get(provider, p) + if df is not None and 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 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( + 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. 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() + 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 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] + 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 ------------------------------------- + # 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: + 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: + from flextool.engine_polars._solve_state import LineageFilterError + raise LineageFilterError( + "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. @@ -676,6 +1680,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 +1697,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 +1720,21 @@ 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) + # Slice D per-path total caps (design §7.2): the (d, leaf) partition + # that keeps entity/group total caps per-scenario-path. Single + # "__anchor" 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``). _new_pd_bw = pd_branch_weight_param( @@ -760,10 +1785,20 @@ def apply_branch_cluster( "pdt_branch_weight_lf", "dt_non_anticipativity_lf", "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", + "pd_non_anticipativity_df", "pd_branch_weight_param", "pdt_branch_weight_param", "dt_non_anticipativity_df", "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..40381bc0b 100644 --- a/flextool/engine_polars/_derived_existing.py +++ b/flextool/engine_polars/_derived_existing.py @@ -310,6 +310,288 @@ 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() + + # [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. + 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 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")) + + # [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")) + .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")) + + out_df = out.collect() + if out_df.height == 0: + return None + 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( + 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 # --------------------------------------------------------------------------- @@ -387,6 +669,9 @@ 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, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_choice``. @@ -394,10 +679,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`` / ``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) + method="reinvest_choice", bounded=True, workdir=workdir, + lineage=lineage, commission_lf=commission_lf) def edd_history_automatic_lf(source: "InputSource", @@ -405,6 +694,9 @@ 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, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for ``edd_history_automatic`` — entities with ``lifetime_method = reinvest_automatic``. @@ -412,10 +704,14 @@ def edd_history_automatic_lf(source: "InputSource", Mirror of ``invest_divest_sets.py:245-246``:: keep iff pdy[d] >= pdy[d_h] + + ``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) + method="reinvest_automatic", bounded=False, workdir=workdir, + lineage=lineage, commission_lf=commission_lf) def edd_history_no_investment_lf(source: "InputSource", @@ -423,15 +719,22 @@ 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, + 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`` / ``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) + method="no_investment", bounded=True, workdir=workdir, + lineage=lineage, commission_lf=commission_lf) def _edd_history_lf_for(source: "InputSource", @@ -442,9 +745,15 @@ def _edd_history_lf_for(source: "InputSource", method: str, 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`` / ``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={ @@ -478,14 +787,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, 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) + workdir=workdir, lineage=lineage, commission_lf=commission_lf) return walk.pipe(rename_to_axis, {"d": "d_history", "d_all": "d"}) @@ -494,6 +803,9 @@ 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, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_history, d]`` for the union ``edd_history`` set. @@ -502,6 +814,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`` / ``commission_lf`` — see + :func:`._derived_walks.period_walk_iterator`. """ if not period_with_history or not period_in_use: return pl.LazyFrame(schema={ @@ -512,13 +827,16 @@ def edd_history_lf(source: "InputSource", parts = [ edd_history_choice_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage, + commission_lf=commission_lf), edd_history_automatic_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage, + commission_lf=commission_lf), edd_history_no_investment_lf(source, active_solve, period_with_history, period_in_use, - workdir), + workdir, lineage=lineage, + commission_lf=commission_lf), ] return pl.concat(parts, how="vertical").unique() @@ -669,6 +987,9 @@ 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, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_history_invest`` filtered to ``(e, d_invest) ∈ ed_invest``. @@ -682,10 +1003,14 @@ 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, + 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") @@ -698,6 +1023,9 @@ 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, + commission_lf: pl.LazyFrame | None = None, ) -> pl.LazyFrame: """Lazy ``[e, d_invest, d]`` — ``edd_invest_lookback_set``. @@ -722,6 +1050,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 +1112,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, commission_lf=commission_lf) # Unbounded cohort — strict-lookback only, no lifetime cap. unbounded_anchor = anchor_lf.join(unbounded_e, on="e", how="inner") @@ -795,7 +1126,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, 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_npv.py b/flextool/engine_polars/_derived_npv.py index 80af8cc52..19b92362c 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"))) @@ -368,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: @@ -395,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. @@ -424,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), @@ -526,6 +535,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 +546,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 +560,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 +705,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 +720,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 +766,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 +810,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 +823,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 +858,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 +906,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 +919,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 +955,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 +998,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 +1009,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 +1041,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 +1132,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 +1145,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 +1169,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 +1200,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 +1231,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()) @@ -1223,12 +1286,42 @@ 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, + _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 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_annuity_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) + 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 = _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)) @@ -1267,22 +1360,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 3be8c27c7..64b246a5a 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 CommissioningLagError, LineageFilterError from ._param_shapes import ( broadcast_to_period_time, promote_param_to_dt, @@ -113,6 +114,276 @@ 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. + + 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] + 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 + +# 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": + """The active boundary's recourse walker Provider (α-1), or ``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, + *, + 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 + ctx = _recourse_usable_ctx(ctx) + 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 _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, + *, + 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 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_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) + + +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. 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: + 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 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 + + +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 @@ -2647,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 # =========================================================================== @@ -2657,6 +2974,7 @@ def p_slope_from_source(source: "InputSource", "p_flow_constraint_coef", "p_pssdt_varCost", "p_slope", + "p_min_load", ) @@ -2842,6 +3160,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 @@ -3266,7 +3593,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()) # --------------------------------------------------------------------------- @@ -3422,6 +3751,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. @@ -3441,7 +3773,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")}) @@ -3470,6 +3804,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. @@ -3489,7 +3826,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"), @@ -3531,6 +3870,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``): @@ -3539,7 +3882,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")}) @@ -3740,6 +4085,8 @@ def edd_invest_lookback_set_from_source(source: "InputSource", workdir: Path | None = None, *, 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). @@ -3763,6 +4110,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={ @@ -3788,7 +4138,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, commission_lf=commission_lf).collect() def edd_divest_active_from_source(source: "InputSource", @@ -3796,11 +4147,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={ @@ -3826,11 +4189,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()) @@ -4010,7 +4384,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", @@ -5251,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 # --------------------------------------------------------------------------- @@ -5546,6 +6037,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", @@ -5570,6 +6105,37 @@ 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) + # 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 — 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, + ) + _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 # apply_derived_a's path; fall-throughs noted inline). @@ -5651,11 +6217,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 @@ -5715,7 +6283,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) @@ -5747,9 +6316,36 @@ 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 ( + assert_no_forced_out_of_horizon_commission as _assert_no_forced_ooh, + ) + # [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: 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, + commission_lf=commission_lf) + except (LineageFilterError, CommissioningLagError): + raise except Exception: eil_db = None if eil_db is not None and eil_db.height > 0: @@ -5762,14 +6358,16 @@ 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).collect() + period_with_history, period_in_use, workdir, + lineage=_c_lineage, commission_lf=commission_lf).collect() + except (LineageFilterError, CommissioningLagError): + raise except Exception: edd_inv_db = None if edd_inv_db is not None and edd_inv_db.height > 0: @@ -5778,7 +6376,10 @@ 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: edda_db = None if edda_db is not None and edda_db.height > 0: @@ -6387,6 +6988,34 @@ 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 + # 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=_recourse_usable_ctx(ctx), + provider=provider) + + def _dt_period_active_steps_from_workdir( source: "InputSource", workdir: Path, @@ -6975,6 +7604,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] @@ -8429,6 +9077,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 @@ -8451,6 +9101,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: @@ -9117,6 +9788,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``, @@ -9130,7 +9802,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") @@ -10120,13 +10794,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 @@ -10134,7 +10812,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: @@ -10174,10 +10852,43 @@ 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. + # [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 ( + assert_no_forced_out_of_horizon_commission as _assert_no_forced_ooh, + ) + _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. + 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, + ) + _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(), - period_with_history, period_in_use, workdir).collect() + period_with_history, period_in_use, workdir, + lineage=_s_lineage, commission_lf=commission_lf).collect() + except (LineageFilterError, CommissioningLagError): + 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 ddb7265d8..1e57c0d6f 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,87 @@ 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: + 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 " + "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 +195,9 @@ def period_walk_iterator( life_lf: pl.LazyFrame | None, factor_side: str | None, 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. @@ -140,6 +237,33 @@ 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). + 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 ------- @@ -150,6 +274,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 (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"], @@ -160,7 +297,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: @@ -213,10 +355,69 @@ 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: + 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: + raise CommissioningLagError(_desync_msg) + 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() @@ -226,8 +427,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() @@ -237,10 +439,18 @@ 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 + # 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") @@ -248,7 +458,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/flextool/engine_polars/_direct_params.py b/flextool/engine_polars/_direct_params.py index 27c8d6da4..f628ca458 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 @@ -1600,6 +1598,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`` ∪ @@ -1935,9 +1962,18 @@ 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) - # Per-output capacity_min_coeff — the optional per-output floor of - # multi-output units (minFlow_output_floor). Assigned in pass 1a so it is present on the synthetic / rolling + # ``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. NB: ``p_min_load`` is a + # per-unit floor — the ``minFlow_minload`` constraint (``model.py``) sums + # flow over all of a unit's outputs — and the (p, d, t) shape lets that + # per-unit floor vary over time; the two compose. + # Per-output ``capacity_min_coeff`` — the optional per-output floor of + # multi-output units (minFlow_output_floor). Assigned in pass 1a so it is + # present on the synthetic / rolling # sub-solve path too (passes 3-10 are skipped there). flex_data.p_process_sink_min_capacity_coef = ( p_process_sink_min_capacity_coef_from_source(source)) @@ -2154,6 +2190,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/_dump_csvs.py b/flextool/engine_polars/_dump_csvs.py index b8f50e4b3..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, @@ -810,6 +816,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_per_solve.py b/flextool/engine_polars/_emit_per_solve.py index f384de89b..d3e44b240 100644 --- a/flextool/engine_polars/_emit_per_solve.py +++ b/flextool/engine_polars/_emit_per_solve.py @@ -127,12 +127,43 @@ 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, + na_invest_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). + + *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" @@ -334,18 +365,50 @@ 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())) + # 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_period_calc.py b/flextool/engine_polars/_emit_period_calc.py index 2f598535d..26b4d68f2 100644 --- a/flextool/engine_polars/_emit_period_calc.py +++ b/flextool/engine_polars/_emit_period_calc.py @@ -468,11 +468,26 @@ def w(b: str) -> float: period_in_use = _read_singles(solve_data_dir / "period_in_use_set.csv", provider=provider) + # 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: @@ -480,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 @@ -489,6 +506,10 @@ def w(b: str) -> float: pdt_rows: list[tuple[str, str, float]] = [] for d, t in dt_pairs: + 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()) 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 b in phantom: + # Exclude the phantom trunk from the forecast denominator. + continue denom += w(b) if denom == 0.0: continue diff --git a/flextool/engine_polars/_emit_solve_time.py b/flextool/engine_polars/_emit_solve_time.py index 3876681cd..a114af47d 100644 --- a/flextool/engine_polars/_emit_solve_time.py +++ b/flextool/engine_polars/_emit_solve_time.py @@ -150,7 +150,20 @@ 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) + # 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, + na_invest_window=na_invest_window, + ) _ck("per_solve_sets") # ── L1 batch 13: period_calculated_params ───────────────────────── 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/_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/_native_run_model.py b/flextool/engine_polars/_native_run_model.py index c98fb217c..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( @@ -665,6 +750,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/_orchestration.py b/flextool/engine_polars/_orchestration.py index 5dfe44ae0..41a79a7f9 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,274 @@ def _write_scale_csv( # --------------------------------------------------------------------------- +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 _dispatch_realization_raw_guard(solve_cfg, base_name: str) -> None: + """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 (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 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"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``. + + ════════════════════════════════════════════════════════════════════ + 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 + 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. @@ -1578,6 +1849,23 @@ 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 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: + _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 @@ -1941,6 +2229,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) @@ -2093,6 +2391,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 +2647,23 @@ def _on_subsolve(entry: dict) -> None: def run(self, complete_solve_name: str) -> int: _phase_prof("run_enter") + # 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) + _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/_param_shapes.py b/flextool/engine_polars/_param_shapes.py index b8c19f403..e44cdc7f2 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 @@ -418,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` # --------------------------------------------------------------------------- @@ -952,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, @@ -1103,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 @@ -1306,9 +1475,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 +1540,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 +1585,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 +1612,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/flextool/engine_polars/_provider_keys.py b/flextool/engine_polars/_provider_keys.py index 88e65c42d..094959a52 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" # --------------------------------------------------------------------------- @@ -141,6 +142,8 @@ 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_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" @@ -165,6 +168,8 @@ 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_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 f079ded01..28daf1c79 100644 --- a/flextool/engine_polars/_provider_translators.py +++ b/flextool/engine_polars/_provider_translators.py @@ -38,6 +38,9 @@ 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_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"), @@ -77,6 +80,8 @@ 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), + ("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), @@ -104,6 +109,8 @@ 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_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/_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/flextool/engine_polars/_reserve.py b/flextool/engine_polars/_reserve.py index d309bc6d9..de36fd0e3 100644 --- a/flextool/engine_polars/_reserve.py +++ b/flextool/engine_polars/_reserve.py @@ -86,6 +86,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) ) @@ -796,6 +797,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/_solve_config.py b/flextool/engine_polars/_solve_config.py index b435e7a56..7d94893ac 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 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 @@ -21,6 +21,7 @@ """ from __future__ import annotations +import copy import logging from collections import defaultdict from dataclasses import dataclass, field @@ -247,6 +248,8 @@ def __init__( benders_tolerance: dict | None = None, 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 @@ -310,6 +313,23 @@ 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 {} + ) + + # 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) @@ -317,6 +337,35 @@ 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] = {} + # 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) + + # 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] = [] @@ -523,6 +572,23 @@ 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, + ) + + # 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 @@ -630,10 +696,12 @@ 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, + dispatch_realization_method=dispatch_realization_method, ) # Computed fields — loading order MUST be preserved exactly. - # ``duplicate_solve`` mutates 19 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() @@ -650,6 +718,24 @@ 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="shared_operation_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="shared_invest_periods" + ) return obj @@ -794,9 +880,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 21 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 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 + 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 @@ -822,6 +912,8 @@ def duplicate_solve( self.realized_invest_periods, 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, @@ -839,6 +931,97 @@ 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. + """ + # ►► 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: + 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, []) + ) + # 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 # ------------------------------------------------------------------ @@ -877,6 +1060,50 @@ 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 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*. @@ -989,6 +1216,67 @@ 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 = "shared_operation_periods", + ) -> dict[str, list | None]: + """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 + 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. + 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( + 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"]) + # 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 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: + name = entity["name"] + result[name] = present[name] if name in present else None + return result + __all__ = [ "DictMode", diff --git a/flextool/engine_polars/_solve_handoff.py b/flextool/engine_polars/_solve_handoff.py index 69abf2d16..5602974a8 100644 --- a/flextool/engine_polars/_solve_handoff.py +++ b/flextool/engine_polars/_solve_handoff.py @@ -26,6 +26,8 @@ realized_existing [entity, period, value] 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] @@ -115,6 +117,42 @@ 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 + + # 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 @@ -142,7 +180,8 @@ 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", + "online_history", "fix_storage_quantity", "fix_storage_price", "fix_storage_usage", "cumulative_co2", "cumulative_commodity", "cum_sim_hours", ) diff --git a/flextool/engine_polars/_solve_state.py b/flextool/engine_polars/_solve_state.py index f5b8e6b2e..65d41017f 100644 --- a/flextool/engine_polars/_solve_state.py +++ b/flextool/engine_polars/_solve_state.py @@ -39,6 +39,47 @@ 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. + """ + + +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/flextool/engine_polars/_spinedb_reader.py b/flextool/engine_polars/_spinedb_reader.py index 448cbf1ac..5b790a847 100644 --- a/flextool/engine_polars/_spinedb_reader.py +++ b/flextool/engine_polars/_spinedb_reader.py @@ -18,12 +18,15 @@ """ from __future__ import annotations +import copy +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 +34,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 +148,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 @@ -277,6 +293,116 @@ 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. + #: + #: ►► 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", + "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 @@ -390,6 +516,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 +559,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 +1003,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 +1367,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/flextool/engine_polars/_stochastic.py b/flextool/engine_polars/_stochastic.py index 09140012a..31cf4d885 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 @@ -360,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], @@ -394,6 +444,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. @@ -413,9 +473,16 @@ 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 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) @@ -440,30 +507,121 @@ 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 + # ---------------------------------------------------------- + # 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. + # + # 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( + getattr(self.state, "solve", None), "solve_modes", {} + ).get(complete_solves[solve], "single_solve") + == "rolling_window" + ) - # 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 + declared_start_steps: defaultdict[str, set] = defaultdict(set) 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" + 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). 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) + 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)" + ), ) - 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) + self._validate_cohort( + info, + period_k, + None, + f"Stochastic branching period {period_k!r}", + ) # Walk periods — at the first branch trigger, fan out into # branches; subsequent periods continue the same branches. @@ -573,9 +731,34 @@ 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, 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. 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). + # + # ``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, @@ -600,14 +783,39 @@ 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) 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 + if ( + reveal_period is not None + and period in order + and reveal_period in order + and order.index(period) < order.index(reveal_period) + ): + 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/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/_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 diff --git a/flextool/engine_polars/_warm.py b/flextool/engine_polars/_warm.py index 52ea1b525..db0e0c1f9 100644 --- a/flextool/engine_polars/_warm.py +++ b/flextool/engine_polars/_warm.py @@ -330,6 +330,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", @@ -439,6 +440,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/autoscale/_layer2_types.py b/flextool/engine_polars/autoscale/_layer2_types.py index e7c6091df..897260487 100644 --- a/flextool/engine_polars/autoscale/_layer2_types.py +++ b/flextool/engine_polars/autoscale/_layer2_types.py @@ -283,6 +283,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) @@ -337,6 +349,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( @@ -444,6 +464,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 @@ -469,10 +494,19 @@ 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), + # ── 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/autoscale/_quantity_types.py b/flextool/engine_polars/autoscale/_quantity_types.py index 017bf9975..c3264dac3 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, @@ -347,6 +359,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' ... + ('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, @@ -439,6 +455,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, @@ -527,6 +545,10 @@ 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. + ('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/engine_polars/input.py b/flextool/engine_polars/input.py index 923269cd0..23ab01124 100644 --- a/flextool/engine_polars/input.py +++ b/flextool/engine_polars/input.py @@ -642,7 +642,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) — per-unit min-load floor (minFlow_minload), time-varying p_process_sink_min_capacity_coef: Param | None = None # (p, sink) — capacity_min_coeff: optional per-output floor (minFlow_output_floor), default 0 p_startup_cost: Param | None = None # (p, d) p_section: Param | None = None # (p, d, t) @@ -650,6 +650,29 @@ 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,) + # 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 @@ -830,6 +853,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) @@ -934,6 +958,30 @@ 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) + # Slice D — per-scenario-leaf partition of period_in_use (design §7.2): + # (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 "__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) + + # 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`` @@ -2241,6 +2289,9 @@ 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, + online_startup_history=None, online_min_time=None, + uptime_carry=None, downtime_carry=None, ) if pss is None: return blank @@ -2290,8 +2341,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`` @@ -2329,6 +2383,81 @@ 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) + + # 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, @@ -2345,6 +2474,11 @@ 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, + online_startup_history=online_startup_history, + online_min_time=online_min_time, + uptime_carry=None, + downtime_carry=None, ) @@ -4829,6 +4963,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") @@ -4878,6 +5019,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) @@ -4966,6 +5115,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) + # DELAY-1 — same class of gap: ``apply_derived_g`` produces # ``dtt__delay_duration``, ``p_process_delay_weight`` and # ``process_delayed__duration`` together (one source read, Float64 @@ -5016,6 +5187,102 @@ 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: 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. + # + # ``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, + 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) + + # 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)) + # 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 _loadflex_prof("apply_db_overrides:pass_derived_a") @@ -5674,6 +5941,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) @@ -5718,6 +5990,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", @@ -5772,6 +6069,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)) @@ -5795,17 +6132,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`` @@ -5887,6 +6233,208 @@ 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") + + # ---- 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 @@ -6501,6 +7049,8 @@ 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, + 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 94daa09bf..28090fadf 100644 --- a/flextool/engine_polars/model.py +++ b/flextool/engine_polars/model.py @@ -124,17 +124,84 @@ 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 ``"__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 + ``(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, 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] @@ -158,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) @@ -299,6 +368,110 @@ def _add_non_anticipativity_constraints( rhs_terms = {}, ) + # 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), 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. + # + # 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 + 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. @@ -392,6 +565,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``. @@ -2933,14 +3185,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 @@ -2958,14 +3212,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 @@ -2985,13 +3241,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 @@ -3010,13 +3269,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") @@ -4027,19 +4289,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"})) \ @@ -4048,9 +4326,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"})) \ @@ -4059,9 +4337,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"), @@ -4071,9 +4349,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"), @@ -4083,9 +4361,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: @@ -4155,6 +4433,16 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: 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: @@ -4211,22 +4499,28 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: _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 @@ -4257,14 +4551,110 @@ def _add_node_profile_cstr(idx: "pl.DataFrame", name: str, sense: str) -> None: 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) ──────────────────────────────────────── + # 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. + _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( + "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 / " + "recourse-later)." ) # Apply objective scaling if provided (default 1.0 = no scaling). @@ -4730,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, @@ -4803,15 +5224,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 @@ -4906,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. @@ -4945,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/flextool/export_to_tabular/export_settings.yaml b/flextool/export_to_tabular/export_settings.yaml index 9b403b751..18039322d 100644 --- a/flextool/export_to_tabular/export_settings.yaml +++ b/flextool/export_to_tabular/export_settings.yaml @@ -84,10 +84,12 @@ 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", "dispatch_realization_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", + "shared_operation_periods", "shared_invest_periods"] rolling_parameters: params: ["rolling_solve_jump", "rolling_solve_horizon", "rolling_duration"] solve_s_stochastic_branches: @@ -294,6 +296,8 @@ data_type_overrides: realized_invest_periods: "string (2d-map)" realized_periods: "string (2d-map)" fix_storage_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. @@ -315,6 +319,7 @@ period_only_params: - invest_max_period - invest_min_period - lifetime + - construction_lead_time - peak_inflow - retire_max_period - retire_min_period @@ -336,6 +341,7 @@ period_only_params: - invest_max_period - invest_min_period - lifetime + - construction_lead_time - retire_max_period - retire_min_period - salvage_value @@ -351,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/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 445dbeff1..c48f03e15 100644 --- a/flextool/process_outputs/calc_costs.py +++ b/flextool/process_outputs/calc_costs.py @@ -1,6 +1,8 @@ +import numpy as np 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: @@ -37,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) @@ -142,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) @@ -163,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 × @@ -201,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) @@ -217,6 +237,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/calc_slacks.py b/flextool/process_outputs/calc_slacks.py index d2fb2ccbb..e5ee49fb4 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). @@ -105,3 +117,11 @@ def compute_slacks(par, s, v, r) -> None: .mul(par.timestep_weight, axis=0) \ .mul(par.reserve_upDown_group_penalty.reindex(v.q_reserve.columns), axis=1) \ .mul(par.reserve_upDown_group_shortfall_scale, 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, + # ``vq_reserve · scale · penalty · op_factor``). ``shortfall_scale`` is + # the same multiplier the LP uses (it equals the reservation for + # timeseries-only groups), so it is applied once here — not alongside + # ``reservation``. 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 50f46f0c6..a185f051b 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_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 9d7787941..a2a906038 100644 --- a/flextool/process_outputs/read_parameters.py +++ b/flextool/process_outputs/read_parameters.py @@ -1445,6 +1445,25 @@ 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, + ) + # 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( flex_data.p_inflation_op, solve_name=solve_name, diff --git a/flextool/schemas/canonical_databases/howto_aggregate_output.json b/flextool/schemas/canonical_databases/howto_aggregate_output.json index 7215379e6..4c6132bdc 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", [ - "70", + "76", "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", @@ -2056,6 +2088,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", @@ -2165,6 +2205,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", @@ -2256,6 +2307,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", @@ -2307,6 +2374,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", @@ -2393,6 +2471,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", @@ -2984,6 +3078,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", @@ -3632,6 +3744,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", @@ -4268,6 +4398,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", @@ -4730,6 +4878,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", [ @@ -4779,6 +4955,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5374,6 +5564,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 dfa7781dd..9cc97d5b5 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", [ - "70", + "76", "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", @@ -1702,6 +1734,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", @@ -1811,6 +1851,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", @@ -1894,6 +1945,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", @@ -1945,6 +2012,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", @@ -2031,6 +2109,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", @@ -2638,6 +2732,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", @@ -3286,6 +3398,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", @@ -3922,6 +4052,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", @@ -4384,6 +4532,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", [ @@ -4433,6 +4609,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5028,6 +5218,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 0a74732a1..e650e7882 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", [ - "70", + "76", "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", @@ -1618,6 +1650,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,6 +1767,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", @@ -1810,6 +1861,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", @@ -1861,6 +1928,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", @@ -1947,6 +2025,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", @@ -2554,6 +2648,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", @@ -3202,6 +3314,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", @@ -3838,6 +3968,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", @@ -4300,6 +4448,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", [ @@ -4349,6 +4525,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -4944,6 +5134,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 616df064a..f58ee9fbe 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", [ - "70", + "76", "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", @@ -1871,6 +1903,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", @@ -1980,6 +2020,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", @@ -2063,6 +2114,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", @@ -2114,6 +2181,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", @@ -2200,6 +2278,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", @@ -2807,6 +2901,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", @@ -3455,6 +3567,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", @@ -4091,6 +4221,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", @@ -4553,6 +4701,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", [ @@ -4602,6 +4778,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5197,6 +5387,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 7228213f0..f6dd7d645 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", [ - "70", + "76", "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", @@ -1720,6 +1752,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", @@ -1829,6 +1869,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", @@ -1912,6 +1963,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", @@ -1963,6 +2030,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", @@ -2049,6 +2127,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", @@ -2656,6 +2750,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", @@ -3304,6 +3416,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", @@ -3940,6 +4070,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", @@ -4402,6 +4550,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", [ @@ -4451,6 +4627,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5046,6 +5236,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 8dddb88f7..f5e98b965 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", [ - "70", + "76", "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", @@ -1738,6 +1770,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", @@ -1847,6 +1887,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", @@ -1930,6 +1981,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", @@ -1981,6 +2048,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", @@ -2067,6 +2145,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", @@ -2674,6 +2768,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", @@ -3322,6 +3434,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", @@ -3958,6 +4088,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", @@ -4420,6 +4568,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", [ @@ -4469,6 +4645,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5064,6 +5254,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 79929dd68..c0a73b3f7 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", [ - "70", + "76", "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", @@ -1689,6 +1721,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", @@ -1798,6 +1838,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", @@ -1881,6 +1932,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", @@ -1932,6 +1999,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", @@ -2018,6 +2096,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", @@ -2625,6 +2719,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", @@ -3273,6 +3385,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", @@ -3909,6 +4039,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", @@ -4371,6 +4519,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", [ @@ -4420,6 +4596,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5015,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_stochastics.json b/flextool/schemas/canonical_databases/howto_stochastics.json index d901b39ff..8f60bc096 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", [ - "70", + "76", "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", @@ -1802,6 +1834,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", @@ -1919,6 +1959,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", @@ -2002,6 +2053,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", @@ -2053,6 +2120,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", @@ -2139,6 +2217,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", @@ -2724,6 +2818,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", @@ -3372,6 +3484,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", @@ -4008,6 +4138,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", @@ -4470,6 +4618,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", [ @@ -4519,6 +4695,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -5121,6 +5311,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ @@ -5772,7 +5976,7 @@ "2day_dispatch", "stochastic_branches", [ - "{\"index_type\": \"str\", \"data\": [[\"period1\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"realized\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"yes\", 1.0]]}]]}], [\"upper\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}], [\"lower\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}], [\"mid\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}]]}]]}", + "{\"index_type\": \"str\", \"data\": [[\"period1\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"realized\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}], [\"upper\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}], [\"lower\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}], [\"mid\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"t0001\", {\"type\": \"map\", \"index_type\": \"str\", \"data\": [[\"no\", 1.0]]}]]}]]}]]}", "map" ], "2day" diff --git a/flextool/schemas/canonical_databases/templates_examples.json b/flextool/schemas/canonical_databases/templates_examples.json index 641c33e26..ff54f9d4d 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", [ - "70", + "76", "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", @@ -2981,6 +3013,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", @@ -3090,6 +3130,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", @@ -3173,6 +3224,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", @@ -3224,6 +3291,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", @@ -3310,6 +3388,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", @@ -3901,6 +3995,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", @@ -4549,6 +4661,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", @@ -5197,6 +5327,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", @@ -5683,6 +5831,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", [ @@ -5732,6 +5908,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -6320,6 +6510,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 0b315359f..95ba5299c 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", [ - "70", + "76", "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", @@ -1557,6 +1589,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", @@ -1674,6 +1714,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", @@ -1757,6 +1808,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", @@ -1808,6 +1875,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", @@ -1894,6 +1972,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", @@ -2485,6 +2579,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", @@ -3133,6 +3245,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", @@ -3769,6 +3899,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", @@ -4231,6 +4379,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", [ @@ -4280,6 +4456,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "\"within_solve\"", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "\"separate_dispatch\"", + "str" + ] + ], [ "energy_margin_methods", [ @@ -4882,6 +5072,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "\"none\"", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "\"recourse\"", + "str" + ] + ], [ "storage_binding_methods", [ 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": [ diff --git a/flextool/schemas/spinedb_schema.json b/flextool/schemas/spinedb_schema.json index ca1385427..d7c0e8d24 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" @@ -349,6 +365,14 @@ "decomposition_schemes", "benders" ], + [ + "dispatch_realization_methods", + "within_solve" + ], + [ + "dispatch_realization_methods", + "separate_dispatch" + ], [ "energy_margin_methods", "none" @@ -709,6 +733,14 @@ "startup_methods", "binary" ], + [ + "stochastic_invest_methods", + "none" + ], + [ + "stochastic_invest_methods", + "recourse" + ], [ "storage_binding_methods", "bind_within_period" @@ -992,6 +1024,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", @@ -1603,7 +1651,7 @@ [ "model", "version", - 70.0, + 76.0, null, "Contains database version information.", "model" @@ -1648,6 +1696,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", @@ -2040,6 +2104,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", @@ -2128,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) 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" + ], [ "solve", "fix_storage_periods", @@ -2208,6 +2288,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", @@ -2253,7 +2349,15 @@ "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" + ], + [ + "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" ], [ @@ -2336,6 +2440,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", @@ -2832,6 +2952,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", @@ -3510,6 +3648,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", @@ -3936,6 +4092,12 @@ "map", 3 ], + [ + "reserve__upDown__group", + "reserve_duration", + "float", + 0 + ], [ "reserve__upDown__group", "reserve_method", @@ -4200,6 +4362,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 71a508833..728fd2eb9 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 = 76 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 ffcda7040..7baed2189 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, @@ -1763,6 +1786,18 @@ def _is_yes(pv): _migrate_v69_backfill_parameter_groups(db) elif next_version == 70: _migrate_v70_capacity_coefficient_descriptions(db) + elif next_version == 71: + _migrate_v71_stochastic_invest_method(db) + elif next_version == 72: + _migrate_v72_non_anticipativity_periods(db) + elif next_version == 73: + _migrate_v73_non_anticipativity_invest_periods(db) + elif next_version == 74: + _migrate_v74_construction_lead_time(db) + elif next_version == 75: + _migrate_v75(db) + elif next_version == 76: + _migrate_v76_dispatch_realization_method(db) else: print("Version invalid") last_completed_version = next_version @@ -3778,6 +3813,490 @@ def _migrate_v69_backfill_parameter_groups(db) -> None: ) +def _migrate_v71_stochastic_invest_method(db) -> None: + """Add the solve.stochastic_invest_method opt-in (v70 -> v71). + + 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' (created if the DB lacks it). + """ + 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." + ), + ) + # 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", + name="stochastic_invest_method", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v71: added solve.stochastic_invest_method " + "(stochastic_invest_methods: none/recourse, default none); " + "opt-in for recourse stochastic investment (Slice D)." + ) + + +def _migrate_v72_non_anticipativity_periods(db) -> None: + """Add the solve.non_anticipativity_periods window knob (v71 -> v72). + + 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." + ), + ) + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_periods", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v72: added solve.non_anticipativity_periods (Array of periods, " + "default unset); decouples the operational non-anticipativity " + "window from realized_periods (Slice G)." + ) + + +def _migrate_v73_non_anticipativity_invest_periods(db) -> None: + """Add the solve.non_anticipativity_invest_periods knob (v72 -> v73). + + 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`` (v72), 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)." + ), + ) + _ensure_parameter_group(db, "solve_advanced", "b56f6f", 87) + db.add_update_item( + "parameter_definition", + entity_class_name="solve", + name="non_anticipativity_invest_periods", + parameter_group_name="solve_advanced", + ) + + _commit_step(db, + "v73: 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_v74_construction_lead_time(db) -> None: + """Add construction_lead_time + _method per invest entity (v73 -> v74). + + 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`` 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", + 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, + "v74: 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." + ) + + +# Positive, non-negated descriptions for the renamed stochastic +# shared-decision window parameters (v75). 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_v75(db) -> None: + """Rename the stochastic shared-decision windows + add reserve_duration + (v74 -> v75). + + 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, + "v75: 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)." + ) + + +_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). + + 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 (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 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"], + ["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=_V76_DISPATCH_REALIZATION_METHOD_DESCRIPTION, + ) + # 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", + name="dispatch_realization_method", + 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, " + "default within_solve); 2-stage seed of a future multi-stage market " + "structure. 'separate_dispatch' accepted (W4b) for a single " + "top-level single_solve — engine auto-spawns the " + "__realized_dispatch sibling." + ) + + _V70_MAX_FLOW_UNCONSTRAINED_DESCRIPTION = ( "[MW] Upper bound assigned to LP variables that have no other cap " "(invest_no_limit / invest_retire_no_limit capacity; flows through " 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/conftest.py b/tests/conftest.py index 1b03e00ab..442e85b4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -490,6 +490,148 @@ 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 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 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 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`` = ``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`. + """ + 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 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 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, @@ -526,6 +668,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. @@ -540,6 +726,13 @@ 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", + "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", + "stoch_two_period_midhorizon_nreal0": + "stoch_two_period_midhorizon_nreal0_db_url", "branch2_parent_period": "branch2_parent_period_db_url", "case14": "case14_db_url", } @@ -582,6 +775,12 @@ def scenario_workdir( h2_trade_parity_db_url, stochastics_pbt_inflow_db_url, stoch_two_period_db_url, + 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, + 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, @@ -637,6 +836,13 @@ 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, + "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, + "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/autoscale/test_registry_coverage.py b/tests/engine_polars/autoscale/test_registry_coverage.py index be19ada54..dc04cf69b 100644 --- a/tests/engine_polars/autoscale/test_registry_coverage.py +++ b/tests/engine_polars/autoscale/test_registry_coverage.py @@ -219,6 +219,24 @@ def test_constraint_registry_covers_engine_sources() -> None: "minFlow_output_floor_linear", "minFlow_output_floor_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", + # 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", ] 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() 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/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..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,15 +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 .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 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). @@ -44,21 +51,36 @@ 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. 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 = 3 # period1 → {upper, lower, mid} + 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 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_realized", "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/fixtures/flex_toy_reserve_storage.py b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py new file mode 100644 index 000000000..8cd1cf3a1 --- /dev/null +++ b/tests/engine_polars/fixtures/flex_toy_reserve_storage.py @@ -0,0 +1,298 @@ +"""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", invest: bool = False, + divest: bool = False, invest_cost: float = 10.0, + max_units: float = 100.0) -> 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). + 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 + + # ── 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)]})) + + # ── 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_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/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..699295e79 --- /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 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=[[ + "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..bf2a6c015 --- /dev/null +++ b/tests/engine_polars/synthetic/test_flex_toy_reserve_storage.py @@ -0,0 +1,237 @@ +"""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) + + +# ── 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) 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_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}, + ] diff --git a/tests/engine_polars/test_configurable_nonanticipativity.py b/tests/engine_polars/test_configurable_nonanticipativity.py new file mode 100644 index 000000000..6e35e4f8f --- /dev/null +++ b/tests/engine_polars/test_configurable_nonanticipativity.py @@ -0,0 +1,299 @@ +"""Slice G — configurable operational non-anticipativity window. + +Design: ``specs/sliceG_configurable_nonanticipativity_design.md`` (§4 +byte-parity, §7 hand-checkable test). + +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: + +* 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 +``shared_operation_periods`` value. + +Hand-verified reference numbers (deterministic LP; see the builder): + +* ``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 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 + +import os + +import polars as pl +import pytest + +SOLVE = "stoch_2p_free" + +# Deterministic LP reference objectives (see the fixture builder). +PINNED_OBJ = 2_037_600.0 +FREE_OBJ = 764_954.1 + + +# --------------------------------------------------------------------------- +# 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 (shared_operation_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), 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"] + # up strictly above, low strictly below — at every step, and the two + # 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 + + +@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. + + 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 == 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 (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 + # 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 +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/engine_polars/test_construction_lead_time_edd.py b/tests/engine_polars/test_construction_lead_time_edd.py new file mode 100644 index 000000000..721e7b54e --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_edd.py @@ -0,0 +1,190 @@ +"""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 ( + 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] +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, + invest_forced=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]}) + 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) + + +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"] + + +# --- §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) 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..dea84c2f2 --- /dev/null +++ b/tests/engine_polars/test_construction_lead_time_guard.py @@ -0,0 +1,231 @@ +"""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_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. +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 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)] + 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 _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() + + +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: + """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"] + + +# --- 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 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 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..18dfe6bb2 --- /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 +(``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* +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 (shared_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 diff --git a/tests/engine_polars/test_create_stochastic_periods_continuation.py b/tests/engine_polars/test_create_stochastic_periods_continuation.py index 36875722c..e86c94984 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,160 @@ 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="at most one"): + _run_one(solver, info, {"p1": ROLL_WIN}) + + +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"), + ] + (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}) + + +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_derived_branch_lineage.py b/tests/engine_polars/test_derived_branch_lineage.py new file mode 100644 index 000000000..8905b798b --- /dev/null +++ b/tests/engine_polars/test_derived_branch_lineage.py @@ -0,0 +1,639 @@ +"""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_annuity_df, + 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"), +] + +# 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"), + ("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 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_STOCH + 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) + + +# --------------------------------------------------------------------------- +# 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 + ``__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) + 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 diff --git a/tests/engine_polars/test_direct_params_delta4.py b/tests/engine_polars/test_direct_params_delta4.py index 6d09e7da4..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(): @@ -138,6 +157,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. @@ -163,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", 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_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 new file mode 100644 index 000000000..563d5d308 --- /dev/null +++ b/tests/engine_polars/test_invest_na_window.py @@ -0,0 +1,291 @@ +"""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 ``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 ``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 + ``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 — + 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), 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_high"] == pytest.approx(60.0) # shared first stage + assert got["p2035_low"] == pytest.approx(60.0) # shared first stage + 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 +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), 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_high"), ("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), 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.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), 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_high"), + ("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), 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() + 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_high"), + ("base", "p2035", "p2035_low")} + + +@pytest.mark.solver +def test_divest_net_capacity_equal_across_branches(na_divest_resolved) -> None: + """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_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) ----------------------------------------------- + + +@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/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_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_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_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 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..c5e221137 --- /dev/null +++ b/tests/engine_polars/test_period_walk_lineage.py @@ -0,0 +1,781 @@ +"""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_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"), + } + 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. + # (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 ------------------------ + + +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), 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)) + 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"] 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"] + ) diff --git a/tests/engine_polars/test_recourse_guards.py b/tests/engine_polars/test_recourse_guards.py new file mode 100644 index 000000000..302a8cb35 --- /dev/null +++ b/tests/engine_polars/test_recourse_guards.py @@ -0,0 +1,293 @@ +"""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 + # ``_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 + + +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_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_rec"]}, + {"s_rec": "recourse"}) + state = dataclasses.make_dataclass("S", ["solve"])(solve=sc) + 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(): + 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/engine_polars/test_recourse_invest_axis.py b/tests/engine_polars/test_recourse_invest_axis.py new file mode 100644 index 000000000..d13fc78be --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_axis.py @@ -0,0 +1,122 @@ +"""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 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 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"] 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..667859436 --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_gate.py @@ -0,0 +1,290 @@ +"""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, 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 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 + +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) — 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.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): 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): the phantom trunk invests 0; each +# forecast leaf invests its own capacity. +EXPECTED_INVEST = { + "p2035": 0.0, "p2040": 0.0, + "p2035_high": 60.0, "p2040_high": 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 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)} + 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 +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_high", "p2035_high"), ("p2035_high", "p2040_high"), + ("p2035_low", "p2035_low"), ("p2035_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, 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": 0.0, "p2040": 0.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), 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") == 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), R3 re-grounded: under recourse the entity total + 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 + 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 + + 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 == 3 # (peaker, {__anchor, high, low}) + leaves = sorted(str(v) for v in over["leaf"].to_list()) + assert leaves == ["__anchor", "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), 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 + 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)} + # Only real-named trunk periods survive the pre-filter — no branch rows. + assert periods == {"p2035", "p2040"} + 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 "210" in msgs[0] + for b in ("p2035_high", "p2040_high", "p2035_low", "p2040_low"): + assert b 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 + 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/engine_polars/test_recourse_invest_hedge.py b/tests/engine_polars/test_recourse_invest_hedge.py new file mode 100644 index 000000000..2245dbc5a --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_hedge.py @@ -0,0 +1,523 @@ +"""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 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 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", "t0005", "yes", "1"), # mid-period (not t0004) + ("p2040", "low", "t0005", "no", "3"), # mid-period (not t0004) + ] + 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): 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="at most one realized:yes" + ): + _run(solver, bad) + + +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() + good = [ + ("p2040", "rlz", "t0004", "no", "1"), + ("p2040", "low", "t0004", "no", "3"), + ] + 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: + """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_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)} + # 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 +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 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). + """ + 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", "__anchor"), ("p2040", "__anchor"), + ("p2035", "high"), ("p2040_high", "high"), + ("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 × {__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 + 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 == 3 + assert sorted(str(v) for v in over["leaf"].to_list()) == [ + "__anchor", "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 (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": 0.0} + + +# --- T-S (storage): NA fires + branch copy links to shared trunk ----------- + + +@pytest.mark.solver +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 + + data = load_flextool(hedge_storage_wf) + pb = Problem() + build_flextool(pb, data) + names = pb.cstr_names() + 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( + 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_high", "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/engine_polars/test_recourse_invest_na.py b/tests/engine_polars/test_recourse_invest_na.py new file mode 100644 index 000000000..26920bc48 --- /dev/null +++ b/tests/engine_polars/test_recourse_invest_na.py @@ -0,0 +1,193 @@ +"""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_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") + + 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 + 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) + 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 across forecasts 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/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) diff --git a/tests/engine_polars/test_recourse_plumbing.py b/tests/engine_polars/test_recourse_plumbing.py new file mode 100644 index 000000000..51467f1b1 --- /dev/null +++ b/tests/engine_polars/test_recourse_plumbing.py @@ -0,0 +1,139 @@ +"""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) + + +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 — provider-only read + absent-default +# --------------------------------------------------------------------------- + + +def test_recourse_active_true(tmp_path): + _write(tmp_path, "method\nrecourse\n") + 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, provider=_seeded_provider(tmp_path)) is False + + +def test_recourse_active_absent_csv_false(tmp_path): + # 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 + + +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 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 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/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}" + ) 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..67277e581 --- /dev/null +++ b/tests/engine_polars/test_rolling_stochastic_na.py @@ -0,0 +1,531 @@ +"""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 +--------------------------------------- +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 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). + +§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 +``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. +""" +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 + +# 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). +# +# 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] + + +@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. + + 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") + 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 ``{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"] + 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. + + 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. + """ + 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 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 — 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 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 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: + + * **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 + 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 + + 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") + 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 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(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") + # 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 + + # 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("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["dmax"] - row["dmin"] <= tol + 1e-6 * max( + 1.0, abs(row["dmax"]), abs(row["dmin"]) + ), ( + 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 + + # ── 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" + ) + 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" + ) + + +# 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)") 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/engine_polars/test_w4a_dispatch_realization_guard.py b/tests/engine_polars/test_w4a_dispatch_realization_guard.py new file mode 100644 index 000000000..a8dec30e1 --- /dev/null +++ b/tests/engine_polars/test_w4a_dispatch_realization_guard.py @@ -0,0 +1,298 @@ +"""W4 — ``dispatch_realization_method`` guard (W4a schema + W4b narrowing). + +``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 because the normalizing accessor +(:meth:`SolveConfig.dispatch_realization_method_for`) would silently +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 + +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 fields the guard/accessor read. +# --------------------------------------------------------------------------- + + +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; keep it inert. + 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" + assert sc.dispatch_realization_method_for("loud") == "within_solve" + 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 — accept an eligible single top-level separate_dispatch solve +# =========================================================================== + + +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, + ) + + 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 "): + 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_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, + ) + + 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, "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 "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 +# =========================================================================== + + +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_rolling_separate_dispatch(): + from flextool.engine_polars._orchestration import _validate_model_solve + + 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(): + from flextool.engine_polars._orchestration import _validate_model_solve + + 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"] + + 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(): + """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"}, + 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(): + 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 + 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..b9430ef95 --- /dev/null +++ b/tests/engine_polars/test_w4b_auto_dispatch.py @@ -0,0 +1,501 @@ +"""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} + # W4c — spawn records the sibling -> parent map on this dict. + sc.realized_dispatch_parent = {} + 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/engine_polars/test_w4c_divergent_dispatch.py b/tests/engine_polars/test_w4c_divergent_dispatch.py new file mode 100644 index 000000000..fd0b41e8a --- /dev/null +++ b/tests/engine_polars/test_w4c_divergent_dispatch.py @@ -0,0 +1,355 @@ +"""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'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 + 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_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 + 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/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/expected/2_day_stochastic_dispatch/costs__dt.csv b/tests/expected/2_day_stochastic_dispatch/costs__dt.csv index 790d05754..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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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_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_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,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,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,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,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 6b072baf4..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-05-15 16:38:18.485410+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.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,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 -Removed, downriver, period1_lower, 14400 -Removed, downriver, period1_mid, 14400 -Removed, downriver, period1_upper, 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/branch2_parent_period.json b/tests/fixtures/branch2_parent_period.json index 3cbe2acb3..443b00eec 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", [ @@ -669,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -1271,6 +1313,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1673,6 +1729,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", @@ -2513,7 +2591,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -2575,6 +2653,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", @@ -3114,6 +3214,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", @@ -3246,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", @@ -3356,6 +3478,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", @@ -3422,6 +3566,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", @@ -3532,6 +3687,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", @@ -4172,6 +4349,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", @@ -4820,6 +5015,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", @@ -5456,6 +5669,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/build_stoch_realized_dispatch.py b/tests/fixtures/build_stoch_realized_dispatch.py new file mode 100644 index 000000000..a318604c9 --- /dev/null +++ b/tests/fixtures/build_stoch_realized_dispatch.py @@ -0,0 +1,283 @@ +"""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``). + 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``. + 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. 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.0)]))])), + ("high", _nested([("t0001", _nested([("no", 0.5)]))])), + ("low", _nested([("t0001", _nested([("no", 0.5)]))])), + ])), + ]) + # 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/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_free_ops.py b/tests/fixtures/build_stoch_two_period_free_ops.py new file mode 100644 index 000000000..852c7a759 --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_free_ops.py @@ -0,0 +1,318 @@ +"""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 (``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). + +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 +``shared_operation_periods`` value): + +* ``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`` — ``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 + 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.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" + 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] == "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", "shared_operation_periods", + _pack(None, None), None, + "Array of periods over which operations are SHARED (identical) " + "across stochastic branches (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", "shared_operation_periods = [] (operations free " + "from t0)"], + ] + spec["scenarios"] = [ + ["free_ops", False, + "shared_operation_periods = [] -> operations branch freely; " + "shared invest, divergent v_state"], + ["pinned_ops", False, + "shared_operation_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", "shared_operation_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/build_stoch_two_period_hedge.py b/tests/fixtures/build_stoch_two_period_hedge.py new file mode 100644 index 000000000..d102a3390 --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_hedge.py @@ -0,0 +1,389 @@ +"""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 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 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 + = 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_high = 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)"], + ["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)"], + ] + 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)"], + ["hedge_na_window", False, + "Invest-NA window main mode: fan-at-t0 + " + "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)"], + ["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"], + ["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], + # 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"], + ["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], + ["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"], "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 -------------------------- + # Mid-horizon reveal (scenario ``hedge``): branches declared at p2040. + stochastic_branches_rp = _map([ + ("p2040", _nested([ + ("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([ + ("high", _nested([("t0001", _nested([("no", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 3.0)]))])), + ])), + ]) + # 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] + ) + # 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 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([ + ("high", _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"], + # ---- 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", "shared_invest_periods", + _pack({"value_type": "str", "data": ["p2035"]}, "array"), + "na_window"], + ["solve", "stoch_2p_hedge", "shared_operation_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 + # (``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 + + +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/build_stoch_two_period_invest.py b/tests/fixtures/build_stoch_two_period_invest.py new file mode 100644 index 000000000..3c9f2a80c --- /dev/null +++ b/tests/fixtures/build_stoch_two_period_invest.py @@ -0,0 +1,257 @@ +"""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, +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`` +× ``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: 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), + 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_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. + +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"], + ["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"] = [ + ["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([ + ("high", _nested([("t0001", _nested([("no", 1.0)]))])), + ("low", _nested([("t0001", _nested([("no", 3.0)]))])), + ])), + ]) + # 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 + # high 40/40 MW, low 20/10 MW → peaker residual high 60/90, low 80/120. + profile_stoch = _map([ + ("high", _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(150.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"], + # ---- horizon: surface non-realized branch invest (§11.2) ------- + ["model", "flextool", "output_horizon", _pack("yes", "str"), + "horizon"], + ] + 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/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/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/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/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/build_w4c_divergent_dispatch.py b/tests/fixtures/build_w4c_divergent_dispatch.py new file mode 100644 index 000000000..dca02d221 --- /dev/null +++ b/tests/fixtures/build_w4c_divergent_dispatch.py @@ -0,0 +1,114 @@ +"""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 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) +----------------------------------------- +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/case14_dc_power_flow.json b/tests/fixtures/case14_dc_power_flow.json index a04300223..07a33619f 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", [ @@ -1707,6 +1735,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2316,6 +2358,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -2725,6 +2781,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", @@ -3565,7 +3643,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -3627,6 +3705,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", @@ -4166,6 +4266,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", @@ -4287,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", @@ -4397,6 +4519,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", @@ -4463,6 +4607,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", @@ -4573,6 +4728,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", @@ -5213,6 +5390,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", @@ -5861,6 +6056,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", @@ -6497,6 +6710,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 b3422d1ff..c0cdf62b8 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", [ @@ -6815,6 +6843,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -7410,6 +7452,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -7819,6 +7875,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", @@ -8659,7 +8737,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -8721,6 +8799,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", @@ -9260,6 +9360,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", @@ -9381,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", @@ -9491,6 +9613,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", @@ -9557,6 +9701,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", @@ -9656,6 +9811,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", @@ -10296,6 +10473,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", @@ -10944,6 +11139,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", @@ -11574,6 +11787,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 addcd8115..c5f6118cd 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", [ @@ -2712,6 +2740,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -3300,6 +3342,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -3667,6 +3723,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", @@ -4507,7 +4585,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -4569,6 +4647,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", @@ -5108,6 +5208,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", @@ -5229,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", @@ -5339,6 +5461,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", @@ -5405,6 +5549,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", @@ -5515,6 +5670,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", @@ -6161,6 +6338,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", @@ -6809,6 +7004,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", @@ -7457,6 +7670,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/stoch_realized_dispatch.json b/tests/fixtures/stoch_realized_dispatch.json new file mode 100644 index 000000000..3b2a56e95 --- /dev/null +++ b/tests/fixtures/stoch_realized_dispatch.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, 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", + [ + "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_free_ops.json b/tests/fixtures/stoch_two_period_free_ops.json new file mode 100644 index 000000000..f01bfd6ae --- /dev/null +++ b/tests/fixtures/stoch_two_period_free_ops.json @@ -0,0 +1,6484 @@ +{ + "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, 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", + "shared_operation_periods = [] (operations free from t0)" + ] + ], + "scenarios": [ + [ + "free_ops", + false, + "shared_operation_periods = [] -> operations branch freely; shared invest, divergent v_state" + ], + [ + "pinned_ops", + false, + "shared_operation_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", + "shared_operation_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogW119", + "array" + ], + "freeops" + ] + ] +} diff --git a/tests/fixtures/stoch_two_period_hedge.json b/tests/fixtures/stoch_two_period_hedge.json new file mode 100644 index 000000000..0c9452817 --- /dev/null +++ b/tests/fixtures/stoch_two_period_hedge.json @@ -0,0 +1,6721 @@ +{ + "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 + 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)" + ] + ], + "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)" + ], + [ + "hedge_na_window", + false, + "Invest-NA window main mode: fan-at-t0 + shared_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": [ + [ + "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 + ], + [ + "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", + "base" + ], + [ + "hedge_storage", + "base", + "stoch" + ], + [ + "hedge_storage", + "stoch", + "rp" + ], + [ + "hedge_storage", + "rp", + "storage" + ], + [ + "hedge_storage", + "storage", + null + ] + ], + "entities": [ + [ + "group", + "stoch_g", + null + ], + [ + "model", + "flextool", + null + ], + [ + "node", + "elec", + null + ], + [ + "node", + "resv", + null + ], + [ + "profile", + "wprof", + null + ], + [ + "solve", + "stoch_2p_hedge", + 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" + ], + "init", + true + ], + [ + "unit", + [ + "base" + ], + "init", + true + ], + [ + "unit", + [ + "wind" + ], + "init", + true + ], + [ + "node", + [ + "resv" + ], + "storage", + true + ], + [ + "unit", + [ + "charge" + ], + "storage", + true + ], + [ + "unit", + [ + "discharge" + ], + "storage", + 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", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjhdLCBbInQwMDAyIiwgMC44XSwgWyJ0MDAwMyIsIDAuOF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuMF0sIFsidDAwMDUiLCAwLjBdLCBbInQwMDA2IiwgMC4wXV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC44XSwgWyJ0MDAwMiIsIDAuOF0sIFsidDAwMDMiLCAwLjhdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjZdLCBbInQwMDA1IiwgMC42XSwgWyJ0MDAwNiIsIDAuNl1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_invest_method", + [ + "InJlY291cnNlIg==", + "str" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwNDAiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "rp" + ], + [ + "solve", + "stoch_2p_hedge", + "stochastic_branches", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "ws" + ], + [ + "solve", + "stoch_2p_hedge", + "shared_invest_periods", + [ + "eyJ2YWx1ZV90eXBlIjogInN0ciIsICJkYXRhIjogWyJwMjAzNSJdfQ==", + "array" + ], + "na_window" + ], + [ + "solve", + "stoch_2p_hedge", + "shared_operation_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", + "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" + ] + ] +} diff --git a/tests/fixtures/stoch_two_period_invest.json b/tests/fixtures/stoch_two_period_invest.json new file mode 100644 index 000000000..05da67c4f --- /dev/null +++ b/tests/fixtures/stoch_two_period_invest.json @@ -0,0 +1,6341 @@ +{ + "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 (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)" + ] + ], + "scenarios": [ + [ + "recourse", + false, + "Per-scenario stochastic investment (flag on)" + ], + [ + "recourse_horizon", + false, + "Recourse + output_horizon (non-realized branch invest visible)" + ] + ], + "scenario_alternatives": [ + [ + "recourse", + "init", + "base" + ], + [ + "recourse", + "base", + "stoch" + ], + [ + "recourse", + "stoch", + "recourse" + ], + [ + "recourse", + "recourse", + null + ], + [ + "recourse_horizon", + "init", + "base" + ], + [ + "recourse_horizon", + "base", + "stoch" + ], + [ + "recourse_horizon", + "stoch", + "recourse" + ], + [ + "recourse_horizon", + "recourse", + "horizon" + ], + [ + "recourse_horizon", + "horizon", + 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", + [ + "MTUwLjA=", + "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", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJoaWdoIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDMuMF1dfV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "profile", + "wprof", + "profile", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1siaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCAwLjRdLCBbInQwMDAyIiwgMC40XSwgWyJ0MDAwMyIsIDAuNF1dfV0sIFsidDAwMDQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwNCIsIDAuNF0sIFsidDAwMDUiLCAwLjRdLCBbInQwMDA2IiwgMC40XV19XV19XSwgWyJsb3ciLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgMC4yXSwgWyJ0MDAwMiIsIDAuMl0sIFsidDAwMDMiLCAwLjJdXX1dLCBbInQwMDA0IiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDQiLCAwLjFdLCBbInQwMDA1IiwgMC4xXSwgWyJ0MDAwNiIsIDAuMV1dfV1dfV1dfQ==", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_2p_inv", + "stochastic_invest_method", + [ + "InJlY291cnNlIg==", + "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/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" + ] + ] +} 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/fixtures/stochastics.json b/tests/fixtures/stochastics.json index 04a1e7963..7ded4c662 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", [ @@ -669,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -1271,6 +1313,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1673,6 +1729,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", @@ -2513,7 +2591,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -2575,6 +2653,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", @@ -3114,6 +3214,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", @@ -3246,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", @@ -3356,6 +3478,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", @@ -3422,6 +3566,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", @@ -3532,6 +3687,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", @@ -4172,6 +4349,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", @@ -4820,6 +5015,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", @@ -5456,6 +5669,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", @@ -6273,7 +6504,7 @@ "2day_dispatch", "stochastic_branches", [ - "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ5ZXMiLCAxLjBdXX1dXX1dLCBbInVwcGVyIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsibG93ZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJtaWQiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XV19XV19", + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicGVyaW9kMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInJlYWxpemVkIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sidDAwMDEiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJubyIsIDEuMF1dfV1dfV0sIFsidXBwZXIiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbIm5vIiwgMS4wXV19XV19XSwgWyJsb3dlciIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dLCBbIm1pZCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAxLjBdXX1dXX1dXX1dXX0=", "map" ], "2day" diff --git a/tests/fixtures/stochastics_pbt_inflow.json b/tests/fixtures/stochastics_pbt_inflow.json index 05bc35886..703b26e01 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", [ @@ -669,6 +697,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -1271,6 +1313,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -1673,6 +1729,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", @@ -2513,7 +2591,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -2575,6 +2653,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", @@ -3114,6 +3214,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", @@ -3246,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", @@ -3356,6 +3478,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", @@ -3422,6 +3566,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", @@ -3532,6 +3687,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", @@ -4172,6 +4349,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", @@ -4820,6 +5015,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", @@ -5456,6 +5669,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 4f72b3136..794ae3fe5 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", [ @@ -1658,6 +1686,20 @@ "str" ] ], + [ + "dispatch_realization_methods", + [ + "IndpdGhpbl9zb2x2ZSI=", + "str" + ] + ], + [ + "dispatch_realization_methods", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ] + ], [ "energy_margin_methods", [ @@ -2246,6 +2288,20 @@ "str" ] ], + [ + "stochastic_invest_methods", + [ + "Im5vbmUi", + "str" + ] + ], + [ + "stochastic_invest_methods", + [ + "InJlY291cnNlIg==", + "str" + ] + ], [ "storage_binding_methods", [ @@ -2613,6 +2669,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", @@ -3453,7 +3531,7 @@ "model", "version", [ - "NzA=", + "NzY=", "float" ], null, @@ -3515,6 +3593,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", @@ -4054,6 +4154,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", @@ -4175,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", @@ -4285,6 +4407,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", @@ -4351,6 +4495,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", @@ -4461,6 +4616,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", @@ -5107,6 +5284,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", @@ -5755,6 +5950,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", @@ -6403,6 +6616,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/w4b_auto_dispatch.json b/tests/fixtures/w4b_auto_dispatch.json new file mode 100644 index 000000000..b8abe7a5d --- /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", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", + "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..f800ee447 --- /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", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", + "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" + ] + ] +} diff --git a/tests/fixtures/w4c_divergent_dispatch.json b/tests/fixtures/w4c_divergent_dispatch.json new file mode 100644 index 000000000..356070e6a --- /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", + [ + "eyJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sicDIwMzUiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJybHoiLCB7InR5cGUiOiAibWFwIiwgImluZGV4X3R5cGUiOiAic3RyIiwgImRhdGEiOiBbWyJ0MDAwMSIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInllcyIsIDAuMF1dfV1dfV0sIFsiaGlnaCIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dLCBbImxvdyIsIHsidHlwZSI6ICJtYXAiLCAiaW5kZXhfdHlwZSI6ICJzdHIiLCAiZGF0YSI6IFtbInQwMDAxIiwgeyJ0eXBlIjogIm1hcCIsICJpbmRleF90eXBlIjogInN0ciIsICJkYXRhIjogW1sibm8iLCAwLjVdXX1dXX1dXX1dXX0=", + "map" + ], + "stoch" + ], + [ + "solve", + "stoch_rd", + "dispatch_realization_method", + [ + "InNlcGFyYXRlX2Rpc3BhdGNoIg==", + "str" + ], + "init" + ] + ] +} 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/scenarios.yaml b/tests/scenarios.yaml index 073cbb585..2069dd530 100644 --- a/tests/scenarios.yaml +++ b/tests/scenarios.yaml @@ -754,6 +754,21 @@ # 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. + # + # 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/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_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_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_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_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 # --------------------------------------------------------------------------- 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)) diff --git a/tests/test_recourse_cost_reconciliation.py b/tests/test_recourse_cost_reconciliation.py new file mode 100644 index 000000000..1223a1f10 --- /dev/null +++ b/tests/test_recourse_cost_reconciliation.py @@ -0,0 +1,111 @@ +"""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 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 + +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 +@pytest.mark.solver +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)") + + # 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) 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?" 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 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" + ) diff --git a/tests/update_flextool/test_v71_stochastic_invest_method.py b/tests/update_flextool/test_v71_stochastic_invest_method.py new file mode 100644 index 000000000..b8883a753 --- /dev/null +++ b/tests/update_flextool/test_v71_stochastic_invest_method.py @@ -0,0 +1,187 @@ +"""Tests for the v71 database migration (Slice C). + +v71 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 + +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 + stochastic_invest_method 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)) + + +# --- 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 _build_v70_solve_db(url: str) -> None: + """Create a minimal v70 DB (``model`` + ``solve`` classes) carrying no + ``stochastic_invest_method`` — 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, keeping this a faithful v71 test + even as later migrations extend the chain. 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 invest method)") + + +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_value_list_manual`` / ``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="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 -------------------- + + +@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 diff --git a/tests/update_flextool/test_v72_non_anticipativity_periods.py b/tests/update_flextool/test_v72_non_anticipativity_periods.py new file mode 100644 index 000000000..dae0e4d6c --- /dev/null +++ b/tests/update_flextool/test_v72_non_anticipativity_periods.py @@ -0,0 +1,197 @@ +"""Tests for the v72 database migration (Slice G). + +v72 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_v72_version_constant_is_at_least_72() -> None: + """The engine must report a schema version >= 72 — the + non_anticipativity_periods lower bound. Later migrations keep raising + the constant, so an exact-equality assertion would regress. + """ + 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 so the parameter can ONLY appear via the v72 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: + """At HEAD the operational shared-window param exists as an unset Array + (no default, no value list) grouped under ``solve_advanced``. Added at + v72 as ``non_anticipativity_periods`` and renamed to + ``shared_operation_periods`` at v75 — 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="shared_operation_periods", + ) + 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 + 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 (legacy-window) + case and stays byte-identical. (Param renamed to + ``shared_operation_periods`` at v75; 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"] == "shared_operation_periods" + ] + assert values == [], ( + "migration must not author any shared_operation_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_periods`` — so the v72 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 (>= v72), + so loading one would start AT v72 and never exercise the v72 block. + ``model.version`` default is pinned to 71 so ``migrate_database(..., + up_to=72)`` runs only the v72 step. The v72 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", 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 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 — the ``add_update_item`` + helpers tolerate re-adds. + + Seeded from scratch at v71 (see ``_build_v71_solve_db``) so it starts + genuinely BELOW v72 and exercises the v72 migration block from below, + independent of the auto-migrated ``test_fixtures`` corpus. + """ + 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 — no crash, parameter still present. + 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_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. + This is the same assertion CI makes; pinning it locally catches a + forgotten regen.""" + assert sync_master_template(verify_only=True) is True diff --git a/tests/update_flextool/test_v73_non_anticipativity_invest_periods.py b/tests/update_flextool/test_v73_non_anticipativity_invest_periods.py new file mode 100644 index 000000000..2eaa09481 --- /dev/null +++ b/tests/update_flextool/test_v73_non_anticipativity_invest_periods.py @@ -0,0 +1,179 @@ +"""Tests for the v73 database migration — the invest-NA window. + +v73 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_v73_version_constant_is_at_least_73() -> None: + """The engine must report a schema version >= 73 — the + non_anticipativity_invest_periods lower bound.""" + assert FLEXTOOL_DB_VERSION >= 73 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at the fixture's old version, which + predates 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)) + + +# --- the migration adds the Array definition (no value list, no default) --- + + +def test_invest_periods_definition_present(tmp_path: Path) -> None: + """At HEAD the investment shared-window param exists as an unset Array + param (no default, no value list) grouped under ``solve_advanced``. + Added at v73 as ``non_anticipativity_invest_periods`` and renamed to + ``shared_invest_periods`` at v75 — 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="shared_invest_periods", + ) + 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"] + assert pdef["parameter_group_name"] == "solve_advanced" + finally: + db.close() + + +def test_migration_adds_no_per_solve_value(tmp_path: Path) -> None: + """The v73 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. (Param renamed to ``shared_invest_periods`` + at v75; 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"] + == "shared_invest_periods" + ] + assert values == [], ( + "migration must not author any " + "shared_invest_periods value" + ) + finally: + db.close() + + +def _build_v72_solve_db(url: str) -> None: + """Create a minimal v72 DB (``model`` + ``solve`` classes) carrying no + ``non_anticipativity_invest_periods`` — so the v73 block is the ONLY + path that can add it. ``model.version`` default pinned to 72 so + ``migrate_database(..., up_to=73)`` runs only the v73 step.""" + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["solve", ()], + ], + parameter_definitions=[ + ["model", "version", 72.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v72 DB (model + solve, no NA invest periods)") + + +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 parameter present.""" + db_path = tmp_path / "v72_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v72_solve_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() + 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 v73 parameter) was committed.""" + assert sync_master_template(verify_only=True) is True diff --git a/tests/update_flextool/test_v74_construction_lead_time.py b/tests/update_flextool/test_v74_construction_lead_time.py new file mode 100644 index 000000000..e12839fe4 --- /dev/null +++ b/tests/update_flextool/test_v74_construction_lead_time.py @@ -0,0 +1,247 @@ +"""Tests for the v74 database migration — construction lead time (Slice H). + +v74 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_v74_version_constant_is_at_least_74() -> None: + """The engine must report a schema version >= 74 — the + construction_lead_time lower bound.""" + assert FLEXTOOL_DB_VERSION >= 74 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON (at its old version, predating v74), + 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 v74 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_v73_invest_db(url: str) -> None: + """Create a minimal v73 DB with the three invest entity classes carrying + no construction_lead_time — so the v74 block is the ONLY path that can + add it. ``model.version`` default pinned to 73.""" + with DatabaseMapping(url, create=True) as db: + _count, errors = import_data( + db, + entity_classes=[ + ["model", ()], + ["unit", ()], + ["connection", ()], + ["node", ()], + ], + parameter_definitions=[ + ["model", "version", 73.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["unit", "u1"], + ["node", "n1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session("Seed v73 DB (invest classes, no lead time)") + + +def test_migration_reaches_v74_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v74 DB migrates to exactly v74, and re-running the v74 step is + a no-op that keeps the parameters present with distinct type ranks.""" + db_path = tmp_path / "v73_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v73_invest_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 + + # Idempotent re-run of the v74 step. + migrate_database(url, up_to=74) + 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 v74 parameters) was committed.""" + assert sync_master_template(verify_only=True) is True diff --git a/tests/update_flextool/test_v75_rename_and_reserve_duration.py b/tests/update_flextool/test_v75_rename_and_reserve_duration.py new file mode 100644 index 000000000..0d01ace67 --- /dev/null +++ b/tests/update_flextool/test_v75_rename_and_reserve_duration.py @@ -0,0 +1,228 @@ +"""Tests for the v75 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_v75_version_constant_is_at_least_75() -> None: + """The engine must report a schema version >= 75 — the rename + + reserve_duration lower bound.""" + assert FLEXTOOL_DB_VERSION >= 75 + + +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 v75" + 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_v74_db(url: str) -> None: + """Create a minimal v74 DB carrying the OLD-named window params (with a + value) on ``solve`` and a ``reserve__upDown__group`` class WITHOUT + reserve_duration — so the v75 block is the ONLY path that renames / + adds. ``model.version`` default pinned to 74 so + ``migrate_database(..., up_to=75)`` runs only the v75 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", 74.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 v74 DB (old window names + reserve class)") + + +def test_migration_renames_adds_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v75 DB migrates to exactly v75: the window params are renamed + (carrying their values), reserve_duration is added, and re-running the + v75 step is a no-op.""" + db_path = tmp_path / "v74_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v74_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 + + 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 v75" + 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 v75 step. + migrate_database(url, up_to=75) + 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 diff --git a/tests/update_flextool/test_v76_dispatch_realization_method.py b/tests/update_flextool/test_v76_dispatch_realization_method.py new file mode 100644 index 000000000..7bc886f12 --- /dev/null +++ b/tests/update_flextool/test_v76_dispatch_realization_method.py @@ -0,0 +1,287 @@ +"""Tests for the v76 database migration (W4 Slice 1 / W4a). + +v76 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 v71 +``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_v76_version_constant_is_at_least_76() -> None: + """The engine must report a schema version >= 76 — the + dispatch_realization_method lower bound. Later migrations keep raising + the constant, so an exact-equality assertion would regress. + """ + assert FLEXTOOL_DB_VERSION >= 76 + + +def _migrated_db(tmp_path: Path) -> DatabaseMapping: + """Build a fixture DB from JSON, migrate it to HEAD, and return an open + mapping carrying the v76 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() + + +_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) + 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_v75_solve_db(url: str) -> None: + """Create a minimal v75 DB (``model`` + ``solve`` classes) carrying no + ``dispatch_realization_method`` — so the v76 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 (>= v76), + so loading one would start AT v76 and never exercise the v76 block. + ``model.version`` default is pinned to 75 so ``migrate_database(..., + up_to=76)`` runs only the v76 step, keeping this a faithful v76 test + even as later migrations extend the chain. The v76 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", 75.0, None, "Database version."], + ], + alternatives=[["Base", ""]], + entities=[ + ["solve", "s1"], + ], + ) + assert not errors, f"seed import errors: {errors[:5]}" + db.commit_session( + "Seed v75 DB (model + solve, no dispatch realization method)" + ) + + +def test_migration_reaches_v76_and_is_idempotent(tmp_path: Path) -> None: + """A pre-v76 DB migrates to exactly v76, and re-running the v76 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 v75 (see ``_build_v75_solve_db``) so it starts + genuinely BELOW v76 and exercises the v76 migration block from below, + independent of the auto-migrated ``test_fixtures`` corpus. + """ + db_path = tmp_path / "v75_base.sqlite" + url = f"sqlite:///{db_path.resolve()}" + _build_v75_solve_db(url) + start_version = _db_version(url) + assert start_version < 76, ( + "seed DB must start below v76 so the v76 block is exercised" + ) + + migrate_database(url, up_to=76) + assert _db_version(url) == 76 + + # Idempotent re-run of the v76 step — no crash, parameter still present. + 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, "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 v76 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