flextool 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Reserve subsystem: per-(reserve, upDown, group) reservation balance and
|
|
2
|
+
per-(process, reserve, upDown, node) reserve allocation upper bounds.
|
|
3
|
+
|
|
4
|
+
Self-contained module that mirrors the four reserve constraints in
|
|
5
|
+
``flextool.mod`` and the ``vq_reserve`` slack term in the objective:
|
|
6
|
+
|
|
7
|
+
* ``reserveBalance_timeseries_eq`` (mod 2408-2431)
|
|
8
|
+
* ``reserveBalance_dynamic_eq`` (mod 2433-2464)
|
|
9
|
+
* ``reserveBalance_up_n_1_eq`` (mod 2466-2496)
|
|
10
|
+
* ``reserveBalance_down_n_1_eq`` (mod 2498-2532)
|
|
11
|
+
* ``reserve_process_upward`` (mod 3451-3472)
|
|
12
|
+
* ``reserve_process_downward`` (mod 3474-3486)
|
|
13
|
+
* ``vq_reserve`` slack penalty in the objective (mod 2100-2101 / objective_audit.md §9.4)
|
|
14
|
+
|
|
15
|
+
Variables introduced by ``add_variables``/``add_constraints``:
|
|
16
|
+
|
|
17
|
+
v_reserve [(p, r, ud, n, d, t)] in prundt (>= 0)
|
|
18
|
+
vq_reserve[(r, ud, ng, d, t)] in reserve__upDown__group × dt (>= 0, <= 1)
|
|
19
|
+
|
|
20
|
+
Module API (mirrors what ``_group_slack`` is expected to look like):
|
|
21
|
+
|
|
22
|
+
has_feature(d) -> bool
|
|
23
|
+
load_data(inp, sd, ...) -> dict of new FlexData fields (caller merges)
|
|
24
|
+
add_variables(m, d) -> dict of new vars (v_reserve, vq_reserve)
|
|
25
|
+
add_constraints(m, d, vars) -> emits the six reserve constraints
|
|
26
|
+
add_objective_terms(m, d, vars, op_factor) -> Expr (+vq_reserve penalty term)
|
|
27
|
+
|
|
28
|
+
Pending downstream patches (FOR A4 / merge agent — listed in
|
|
29
|
+
``audit/integration_manifest.md``): the .mod also adds
|
|
30
|
+
``+ Σ v_reserve * unitsize`` LHS terms to the existing ``maxFlow``,
|
|
31
|
+
``ramp_*``, and ``profile_flow_*`` constraints. Wiring those couplings
|
|
32
|
+
requires *editing those constraints in flextool/model.py*, which this
|
|
33
|
+
module deliberately does not do. See the manifest for the explicit list
|
|
34
|
+
of LHS terms to add.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
import polars as pl
|
|
41
|
+
|
|
42
|
+
from polar_high import Sum, Where, Param
|
|
43
|
+
from polar_high.engine import Var
|
|
44
|
+
|
|
45
|
+
from ._axis_enums import rename_to_axis, schema_dtype
|
|
46
|
+
from ._emit_provider_io import _provider_key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _provider_get(provider, path: "Path") -> "pl.DataFrame | None":
|
|
50
|
+
"""Provider-only fetch. Returns ``None`` when the Provider is
|
|
51
|
+
missing or doesn't carry *path*'s canonical key.
|
|
52
|
+
"""
|
|
53
|
+
if provider is None:
|
|
54
|
+
return None
|
|
55
|
+
key = _provider_key(path)
|
|
56
|
+
if not provider.has(key):
|
|
57
|
+
return None
|
|
58
|
+
return provider.get(key)
|
|
59
|
+
|
|
60
|
+
# Substrate handle for the cascade-wide axis enum vocabulary.
|
|
61
|
+
# Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
|
|
62
|
+
# ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
|
|
63
|
+
# ContextVar) when this is ``None``, so substrate sites pick up
|
|
64
|
+
# activation set by ``load_flextool`` automatically.
|
|
65
|
+
_enums: "dict | None" = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Field requirements
|
|
70
|
+
# These are the names this module will look up on FlexData (``d``). The
|
|
71
|
+
# caller's loader is expected to populate them via load_data() (then merge
|
|
72
|
+
# the returned dict into FlexData).
|
|
73
|
+
|
|
74
|
+
RESERVE_FIELDS: tuple[str, ...] = (
|
|
75
|
+
# core sets
|
|
76
|
+
"reserve_upDown_group", # (r, ud, g) — present iff feature active
|
|
77
|
+
"prundt", # (p, r, ud, n, d, t) — v_reserve domain
|
|
78
|
+
"process_reserve_upDown_node_active", # (p, r, ud, n)
|
|
79
|
+
"group_node", # (g, n)
|
|
80
|
+
# method partitions of reserve__upDown__group
|
|
81
|
+
"reserve_upDown_group_method_timeseries", # (r, ud, g, method) — may be empty
|
|
82
|
+
"reserve_upDown_group_method_dynamic", # (r, ud, g, method)
|
|
83
|
+
"reserve_upDown_group_method_n_1", # (r, ud, g, method)
|
|
84
|
+
# parameters
|
|
85
|
+
"p_process_reserve_upDown_node_reliability", # (p, r, ud, n) — already coalesced
|
|
86
|
+
"pdtReserve_upDown_group_reservation", # (r, ud, g, d, t) — RHS demand
|
|
87
|
+
"p_reserve_upDown_group_penalty_reserve", # (r, ud, g) — objective penalty
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_feature(d) -> bool:
|
|
92
|
+
"""Reserve subsystem is active iff at least one (r, ud, g) tuple exists
|
|
93
|
+
in ``reserve__upDown__group``. An empty file (column headers only) is
|
|
94
|
+
treated as inactive.
|
|
95
|
+
|
|
96
|
+
Mirrors the convention used by every other feature gate in
|
|
97
|
+
``flextool.model.build_flextool``."""
|
|
98
|
+
rug = getattr(d, "reserve_upDown_group", None)
|
|
99
|
+
return rug is not None and rug.height > 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check(d, fields: tuple[str, ...]) -> None:
|
|
103
|
+
"""Same fail-fast contract as ``flextool.model._check``."""
|
|
104
|
+
missing = [f for f in fields if getattr(d, f, None) is None]
|
|
105
|
+
if missing:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"_reserve: feature is active but data fields are not populated "
|
|
108
|
+
f"(None): {missing}. Populate them via _reserve.load_data()."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Loader
|
|
114
|
+
|
|
115
|
+
def load_data(inp: Path | str, sd: Path | str,
|
|
116
|
+
dt: pl.DataFrame | None = None,
|
|
117
|
+
*, provider=None) -> dict:
|
|
118
|
+
"""Read all reserve CSVs from ``inp/`` (input) and ``sd/`` (solve_data),
|
|
119
|
+
return a plain dict of ``field_name -> Param-or-DataFrame``.
|
|
120
|
+
|
|
121
|
+
The caller is expected to merge this dict into FlexData (e.g. via
|
|
122
|
+
``setattr(d, k, v)``). Returns an empty dict if reserves are not
|
|
123
|
+
active in this scenario.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
inp : Path
|
|
128
|
+
``input/`` directory (raw CSVs from preprocessing).
|
|
129
|
+
sd : Path
|
|
130
|
+
``solve_data/`` directory (preprocessed / Python-emitted CSVs).
|
|
131
|
+
dt : DataFrame, optional
|
|
132
|
+
Time index ``(d, t)``. Used to clip pdtReserve_upDown_group to
|
|
133
|
+
rows actually present in the dispatch horizon. If None, the
|
|
134
|
+
loader keeps every row.
|
|
135
|
+
"""
|
|
136
|
+
inp = Path(inp)
|
|
137
|
+
sd = Path(sd)
|
|
138
|
+
out: dict = {}
|
|
139
|
+
|
|
140
|
+
# ── Core (r, ud, g) set ─────────────────────────────────────────────
|
|
141
|
+
rug_path = sd / "reserve__upDown__group.csv"
|
|
142
|
+
rug = _provider_get(provider, rug_path)
|
|
143
|
+
if rug is None or rug.height == 0:
|
|
144
|
+
return out
|
|
145
|
+
rug = rug.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud", "group": "g"}) \
|
|
146
|
+
.select("r", "ud", "g")
|
|
147
|
+
out["reserve_upDown_group"] = rug
|
|
148
|
+
|
|
149
|
+
# ── Method partitions ──────────────────────────────────────────────
|
|
150
|
+
for method, attr in [
|
|
151
|
+
("timeseries", "reserve_upDown_group_method_timeseries"),
|
|
152
|
+
("dynamic", "reserve_upDown_group_method_dynamic"),
|
|
153
|
+
("n_1", "reserve_upDown_group_method_n_1"),
|
|
154
|
+
]:
|
|
155
|
+
path = sd / f"reserve__upDown__group__method_{method}.csv"
|
|
156
|
+
df = _provider_get(provider, path)
|
|
157
|
+
if df is not None and df.height > 0:
|
|
158
|
+
df = df.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud", "group": "g"}) \
|
|
159
|
+
.select("r", "ud", "g", "method")
|
|
160
|
+
else:
|
|
161
|
+
df = pl.DataFrame(schema={
|
|
162
|
+
"r": schema_dtype(_enums, "r"),
|
|
163
|
+
"ud": schema_dtype(_enums, "ud"),
|
|
164
|
+
"g": schema_dtype(_enums, "g"),
|
|
165
|
+
"method": pl.Utf8})
|
|
166
|
+
out[attr] = df
|
|
167
|
+
|
|
168
|
+
# Δ.12-drop: ``prundt`` produced authoritatively by
|
|
169
|
+
# ``apply_derived_g.prundt_from_source``. Seed dropped.
|
|
170
|
+
# Δ.12-drop: ``process_reserve_upDown_node_active`` produced
|
|
171
|
+
# authoritatively by ``apply_derived_d``
|
|
172
|
+
# (``process_reserve_upDown_node_active_from_source``). Seed dropped.
|
|
173
|
+
|
|
174
|
+
# ── process_reserve_upDown_node_increase_reserve_ratio (dynamic RHS) ─
|
|
175
|
+
irr_path = sd / "process_reserve_upDown_node_increase_reserve_ratio.csv"
|
|
176
|
+
irr = _provider_get(provider, irr_path)
|
|
177
|
+
if irr is not None:
|
|
178
|
+
if irr.height > 0:
|
|
179
|
+
irr = irr.pipe(rename_to_axis, {"process": "p", "reserve": "r",
|
|
180
|
+
"upDown": "ud", "node": "n"}) \
|
|
181
|
+
.select("p", "r", "ud", "n")
|
|
182
|
+
else:
|
|
183
|
+
irr = pl.DataFrame(schema={
|
|
184
|
+
"p": schema_dtype(_enums, "p"),
|
|
185
|
+
"r": schema_dtype(_enums, "r"),
|
|
186
|
+
"ud": schema_dtype(_enums, "ud"),
|
|
187
|
+
"n": schema_dtype(_enums, "n")})
|
|
188
|
+
out["process_reserve_upDown_node_increase_reserve_ratio"] = irr
|
|
189
|
+
|
|
190
|
+
# ── process_reserve_upDown_node_large_failure_ratio (n-1 RHS) ──────
|
|
191
|
+
lfr_path = sd / "process_reserve_upDown_node_large_failure_ratio.csv"
|
|
192
|
+
lfr = _provider_get(provider, lfr_path)
|
|
193
|
+
if lfr is not None:
|
|
194
|
+
if lfr.height > 0:
|
|
195
|
+
lfr = lfr.pipe(rename_to_axis, {"process": "p", "reserve": "r",
|
|
196
|
+
"upDown": "ud", "node": "n"}) \
|
|
197
|
+
.select("p", "r", "ud", "n")
|
|
198
|
+
else:
|
|
199
|
+
lfr = pl.DataFrame(schema={
|
|
200
|
+
"p": schema_dtype(_enums, "p"),
|
|
201
|
+
"r": schema_dtype(_enums, "r"),
|
|
202
|
+
"ud": schema_dtype(_enums, "ud"),
|
|
203
|
+
"n": schema_dtype(_enums, "n")})
|
|
204
|
+
out["process_reserve_upDown_node_large_failure_ratio"] = lfr
|
|
205
|
+
|
|
206
|
+
# ── group_node ────────────────────────────────────────────────────
|
|
207
|
+
# Canonical preprocessing target: solve_data/group_node.csv.
|
|
208
|
+
# Fallback: input/group__node.csv (raw user input). Mirrors the
|
|
209
|
+
# defensive read in _group_slack.py:389. Single-source non-defensive
|
|
210
|
+
# reads are vulnerable to the bug class fixed upstream in flextool
|
|
211
|
+
# 042fae23 (preprocessing path typo silently dropped group data).
|
|
212
|
+
gn = None
|
|
213
|
+
gn_paths = (sd / "group_node.csv", inp / "group__node.csv")
|
|
214
|
+
seen_any = False
|
|
215
|
+
for path in gn_paths:
|
|
216
|
+
df = _provider_get(provider, path)
|
|
217
|
+
if df is None:
|
|
218
|
+
continue
|
|
219
|
+
seen_any = True
|
|
220
|
+
if df.height > 0:
|
|
221
|
+
gn = df.pipe(rename_to_axis, {"group": "g", "node": "n"}).select("g", "n").unique()
|
|
222
|
+
break
|
|
223
|
+
if gn is not None:
|
|
224
|
+
out["group_node"] = gn
|
|
225
|
+
elif seen_any:
|
|
226
|
+
out["group_node"] = pl.DataFrame(schema={
|
|
227
|
+
"g": schema_dtype(_enums, "g"),
|
|
228
|
+
"n": schema_dtype(_enums, "n")})
|
|
229
|
+
|
|
230
|
+
# Δ.12-drop: ``p_process_reserve_upDown_node_reliability`` produced
|
|
231
|
+
# authoritatively by ``apply_direct_params`` (Δ.4b). Seed dropped.
|
|
232
|
+
|
|
233
|
+
# ── pdtReserve_upDown_group: long-format reservation timeseries ───
|
|
234
|
+
pdtR_path = sd / "pdtReserve_upDown_group.csv"
|
|
235
|
+
pdtR = _provider_get(provider, pdtR_path)
|
|
236
|
+
if pdtR is not None:
|
|
237
|
+
if pdtR.height > 0:
|
|
238
|
+
pdtR = pdtR.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud",
|
|
239
|
+
"group": "g", "period": "d", "time": "t"})
|
|
240
|
+
res_only = (pdtR.filter(pl.col("param") == "reservation")
|
|
241
|
+
.select("r", "ud", "g", "d", "t", "value")
|
|
242
|
+
.with_columns(value=pl.col("value")
|
|
243
|
+
.cast(pl.Float64, strict=False)
|
|
244
|
+
.fill_null(0.0)))
|
|
245
|
+
if dt is not None and dt.height > 0:
|
|
246
|
+
res_only = res_only.join(dt, on=["d", "t"], how="inner")
|
|
247
|
+
out["pdtReserve_upDown_group_reservation"] = Param(
|
|
248
|
+
("r", "ud", "g", "d", "t"), res_only)
|
|
249
|
+
|
|
250
|
+
# Δ.12-drop: ``p_reserve_upDown_group_penalty_reserve`` /
|
|
251
|
+
# ``p_process_reserve_upDown_node_max_share`` /
|
|
252
|
+
# ``p_process_reserve_upDown_node_large_failure_ratio_value`` /
|
|
253
|
+
# ``p_process_reserve_upDown_node_increase_reserve_ratio_value``
|
|
254
|
+
# produced authoritatively by ``apply_direct_params`` (Δ.4b).
|
|
255
|
+
# Seeds dropped.
|
|
256
|
+
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Variables
|
|
262
|
+
|
|
263
|
+
def add_variables(m, d) -> dict:
|
|
264
|
+
"""Declare ``v_reserve`` and ``vq_reserve`` on the problem ``m``.
|
|
265
|
+
|
|
266
|
+
Returns the two Var objects keyed by name so the constraint emitter
|
|
267
|
+
(and the merge agent's downstream LHS patches) can reference them
|
|
268
|
+
without re-declaring."""
|
|
269
|
+
if not has_feature(d):
|
|
270
|
+
return {}
|
|
271
|
+
_check(d, ("reserve_upDown_group", "prundt", "dt"))
|
|
272
|
+
|
|
273
|
+
# v_reserve domain = prundt (only entries that the preprocessor flagged)
|
|
274
|
+
v_reserve = m.add_var("v_reserve",
|
|
275
|
+
("p", "r", "ud", "n", "d", "t"),
|
|
276
|
+
d.prundt, lower=0.0)
|
|
277
|
+
|
|
278
|
+
# vq_reserve: domain = reserve__upDown__group × dt. Cap at 1 to match
|
|
279
|
+
# the .mod's ``var vq_reserve … <= 1`` declaration.
|
|
280
|
+
rug_dt = d.reserve_upDown_group.join(d.dt, how="cross")
|
|
281
|
+
vq_reserve = m.add_var("vq_reserve",
|
|
282
|
+
("r", "ud", "g", "d", "t"),
|
|
283
|
+
rug_dt, lower=0.0, upper=1.0)
|
|
284
|
+
|
|
285
|
+
return {"v_reserve": v_reserve, "vq_reserve": vq_reserve}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Constraints
|
|
290
|
+
|
|
291
|
+
def _flow_node_view(v_flow: "Var", side: str) -> "Var":
|
|
292
|
+
"""Return a ``v_flow`` *view* whose native ``side`` axis
|
|
293
|
+
(``"source"`` or ``"sink"``) is relabelled to ``n``.
|
|
294
|
+
|
|
295
|
+
The dynamic / n-1 reserve RHS sum ``v_flow`` over flows whose landing
|
|
296
|
+
node coincides with a reserve node ``n``. ``v_flow`` is declared over
|
|
297
|
+
``(p, source, sink, d, t)``; joining its restriction frame on a
|
|
298
|
+
separately-renamed ``n`` column leaves the flow's own ``source`` /
|
|
299
|
+
``sink`` axis untied and un-summed, so ``canonicalise()`` rejects the
|
|
300
|
+
term with an *open dims* error. Relabelling the relevant flow axis to
|
|
301
|
+
``n`` here ties it to the reserve node (and to the ratio Param's ``n``)
|
|
302
|
+
so the enclosing ``Sum`` collapses it cleanly. ``rename_to_axis``
|
|
303
|
+
preserves the underlying LP column ids, so the view references the
|
|
304
|
+
same ``v_flow`` columns and keeps the ``"v_flow"`` name for autoscale.
|
|
305
|
+
"""
|
|
306
|
+
dims = tuple("n" if c == side else c for c in v_flow.dims)
|
|
307
|
+
return Var(
|
|
308
|
+
name=v_flow.name,
|
|
309
|
+
dims=dims,
|
|
310
|
+
frame=v_flow.frame.pipe(rename_to_axis, {side: "n"}),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def add_constraints(m, d, vars: dict) -> None:
|
|
315
|
+
"""Emit reserve constraints. ``vars`` is the dict returned by
|
|
316
|
+
``add_variables`` (keys ``v_reserve``, ``vq_reserve``). ``m``,
|
|
317
|
+
``d`` are the ``Problem`` and ``FlexData``.
|
|
318
|
+
|
|
319
|
+
Caller-provided dependencies on FlexData:
|
|
320
|
+
* ``v_flow`` — read off ``vars["v_flow"]`` if present (needed for
|
|
321
|
+
the dynamic and n-1 RHS terms). If ``v_flow`` is missing, those
|
|
322
|
+
constraint families are emitted with a constant-zero RHS, which
|
|
323
|
+
matches the .mod's ``sum {} … = 0`` behaviour when the underlying
|
|
324
|
+
sets are empty.
|
|
325
|
+
* ``p_unitsize`` — read off ``d.p_unitsize`` (Param keyed on ``p``).
|
|
326
|
+
* ``p_slope`` — read off ``d.p_slope`` (Param keyed on (p, d, t)).
|
|
327
|
+
"""
|
|
328
|
+
if not has_feature(d):
|
|
329
|
+
return
|
|
330
|
+
_check(d, ("reserve_upDown_group", "prundt", "dt",
|
|
331
|
+
"process_reserve_upDown_node_active", "group_node"))
|
|
332
|
+
|
|
333
|
+
v_reserve = vars["v_reserve"]
|
|
334
|
+
vq_reserve = vars["vq_reserve"]
|
|
335
|
+
p_unitsize = d.p_unitsize
|
|
336
|
+
rug = d.reserve_upDown_group # (r, ud, g)
|
|
337
|
+
rug.join(d.dt, how="cross") # (r, ud, g, d, t)
|
|
338
|
+
pruna = d.process_reserve_upDown_node_active # (p, r, ud, n)
|
|
339
|
+
gn = d.group_node.pipe(rename_to_axis, {"n": "n"}) # (g, n)
|
|
340
|
+
|
|
341
|
+
# ── LHS (shared across all four reserveBalance variants) ────────────
|
|
342
|
+
#
|
|
343
|
+
# + Σ_{(p,r,ud,n) ∈ pruna : (g,n) ∈ group_node, (r,ud,g) ∈ rug}
|
|
344
|
+
# v_reserve[p,r,ud,n,d,t] · unitsize[p] · reliability[p,r,ud,n]
|
|
345
|
+
# + Σ … (1var-per-way + source case, multiplied by pdtProcess_slope)
|
|
346
|
+
# + vq_reserve · pdtReserve_reservation
|
|
347
|
+
#
|
|
348
|
+
# Without the slope distinction (V1 simplification — see manifest TODO
|
|
349
|
+
# for the eventual 1var-per-way + source split), we collapse both
|
|
350
|
+
# process partitions into a single sum without the slope factor. This
|
|
351
|
+
# is exact whenever no reserve-providing processes are 1var_per_way
|
|
352
|
+
# (the common case in the test fixtures). The slope-bearing partition
|
|
353
|
+
# can be added by joining ``pruna`` against ``method_1var_per_way`` and
|
|
354
|
+
# ``process_source`` to split the sum and applying ``d.p_slope`` to
|
|
355
|
+
# the source-side rows only.
|
|
356
|
+
|
|
357
|
+
# Restrict pruna to (p, r, ud, n) with (g, n) ∈ group_node and
|
|
358
|
+
# (r, ud, g) ∈ rug (=> bring g into the dim set).
|
|
359
|
+
pruna_g = (pruna.join(gn, on="n", how="inner") # adds g
|
|
360
|
+
.join(rug, on=["r", "ud", "g"], how="inner")
|
|
361
|
+
.select("p", "r", "ud", "n", "g"))
|
|
362
|
+
|
|
363
|
+
if (d.p_process_reserve_upDown_node_reliability is not None
|
|
364
|
+
and pruna_g.height > 0):
|
|
365
|
+
rel = d.p_process_reserve_upDown_node_reliability
|
|
366
|
+
# v_reserve has dims (p, r, ud, n, d, t); multiply by unitsize and
|
|
367
|
+
# reliability (both keyed inside (p, r, ud, n)), then Where-join to
|
|
368
|
+
# pruna_g to add the ``g`` dim, and Sum out (p, n) to leave (r, ud, g, d, t).
|
|
369
|
+
lhs_reserve_core = Sum(
|
|
370
|
+
Where(v_reserve * p_unitsize * rel, pruna_g),
|
|
371
|
+
over=("p", "n"),
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
lhs_reserve_core = None
|
|
375
|
+
|
|
376
|
+
# vq_reserve · pdtReserve_reservation, dims = (r, ud, g, d, t) already
|
|
377
|
+
if d.pdtReserve_upDown_group_reservation is not None:
|
|
378
|
+
lhs_vq = vq_reserve * d.pdtReserve_upDown_group_reservation
|
|
379
|
+
else:
|
|
380
|
+
lhs_vq = vq_reserve
|
|
381
|
+
|
|
382
|
+
# ── reserveBalance_timeseries_eq ─────────────────────────────────────
|
|
383
|
+
method_ts = d.reserve_upDown_group_method_timeseries
|
|
384
|
+
if method_ts is not None and method_ts.height > 0:
|
|
385
|
+
# Restrict the constraint domain to (r, ud, g, d, t) where
|
|
386
|
+
# (r, ud, g) ∈ method_ts.
|
|
387
|
+
ts_rug = method_ts.select("r", "ud", "g").unique()
|
|
388
|
+
ts_dt = ts_rug.join(d.dt, how="cross")
|
|
389
|
+
rhs = (d.pdtReserve_upDown_group_reservation
|
|
390
|
+
if d.pdtReserve_upDown_group_reservation is not None
|
|
391
|
+
else 0.0)
|
|
392
|
+
lhs_terms = {"vq_term": lhs_vq}
|
|
393
|
+
if lhs_reserve_core is not None:
|
|
394
|
+
lhs_terms["reserve_sum"] = Where(lhs_reserve_core, ts_rug)
|
|
395
|
+
m.add_cstr(
|
|
396
|
+
"reserveBalance_timeseries_eq",
|
|
397
|
+
over = ts_dt,
|
|
398
|
+
sense = ">=",
|
|
399
|
+
lhs_terms = lhs_terms,
|
|
400
|
+
rhs_terms = {"demand": rhs} if not isinstance(rhs, float) else {"demand": rhs},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# ── reserveBalance_dynamic_eq ────────────────────────────────────────
|
|
404
|
+
method_dyn = d.reserve_upDown_group_method_dynamic
|
|
405
|
+
if method_dyn is not None and method_dyn.height > 0:
|
|
406
|
+
dyn_rug = method_dyn.select("r", "ud", "g").unique()
|
|
407
|
+
dyn_dt = dyn_rug.join(d.dt, how="cross")
|
|
408
|
+
# RHS: Σ v_flow · unitsize · increase_reserve_ratio (over flows
|
|
409
|
+
# touching n in (g, n)) + Σ pdtNodeInflow · ratio / step_duration.
|
|
410
|
+
# V1: emit *only* the LHS + vq slack vs. RHS = 0 if v_flow / the
|
|
411
|
+
# ratio set are absent. When v_flow is present and the
|
|
412
|
+
# increase_reserve_ratio param is loaded, build the dynamic RHS.
|
|
413
|
+
rhs_terms: dict = {}
|
|
414
|
+
v_flow = vars.get("v_flow")
|
|
415
|
+
irr_set = getattr(d, "process_reserve_upDown_node_increase_reserve_ratio", None)
|
|
416
|
+
irr_param = getattr(d, "p_process_reserve_upDown_node_increase_reserve_ratio_value", None)
|
|
417
|
+
if (v_flow is not None and irr_set is not None and irr_set.height > 0
|
|
418
|
+
and irr_param is not None and d.process_source_sink is not None):
|
|
419
|
+
# join irr_set to group_node to add g, restrict to dyn_rug
|
|
420
|
+
irr_g = (irr_set.join(gn, on="n", how="inner")
|
|
421
|
+
.join(dyn_rug, on=["r", "ud", "g"], how="inner")
|
|
422
|
+
.select("p", "r", "ud", "n", "g"))
|
|
423
|
+
if irr_g.height > 0:
|
|
424
|
+
# All flow rows whose (p, n) (either as source or sink) is
|
|
425
|
+
# listed in process_source_sink contribute. Simplification:
|
|
426
|
+
# use process_source_sink (the union) and let the join
|
|
427
|
+
# over (p, n) pick up both source-side and sink-side rows.
|
|
428
|
+
# The .mod splits noEff/eff for the slope factor; we keep
|
|
429
|
+
# the no-slope variant here and let A4 patch in slope where
|
|
430
|
+
# applicable.
|
|
431
|
+
#
|
|
432
|
+
# rename source/sink so the join landing column is "n"
|
|
433
|
+
pss = d.process_source_sink
|
|
434
|
+
# Sum pieces: source-side (n appears as 'sink') + sink-side
|
|
435
|
+
# (n appears as 'source'). We unify by inner-joining both
|
|
436
|
+
# ways and summing the contributions.
|
|
437
|
+
# NOTE: this is a structural simplification. The .mod's
|
|
438
|
+
# full RHS additionally splits noEff/eff and applies slope
|
|
439
|
+
# to the eff sink-side rows. Listed in manifest TODO.
|
|
440
|
+
pieces = []
|
|
441
|
+
# n on the "sink" side: the reserve node is the flow's
|
|
442
|
+
# sink. Relabel v_flow's ``sink`` axis to ``n`` so it ties
|
|
443
|
+
# to the reserve node (see _flow_node_view).
|
|
444
|
+
v_flow_sink_n = _flow_node_view(v_flow, "sink")
|
|
445
|
+
pss_as_sink = pss.pipe(rename_to_axis, {"sink": "n"}).select("p", "source", "n")
|
|
446
|
+
pieces.append(Sum(
|
|
447
|
+
Where(v_flow_sink_n * p_unitsize * irr_param,
|
|
448
|
+
pss_as_sink.join(irr_g, on=["p", "n"], how="inner")),
|
|
449
|
+
over=("p", "source", "n"),
|
|
450
|
+
))
|
|
451
|
+
# n on the "source" side: the reserve node is the flow's
|
|
452
|
+
# source. Relabel v_flow's ``source`` axis to ``n``.
|
|
453
|
+
v_flow_source_n = _flow_node_view(v_flow, "source")
|
|
454
|
+
pss_as_source = pss.pipe(rename_to_axis, {"source": "n"}).select("p", "n", "sink")
|
|
455
|
+
pieces.append(Sum(
|
|
456
|
+
Where(v_flow_source_n * p_unitsize * irr_param,
|
|
457
|
+
pss_as_source.join(irr_g, on=["p", "n"], how="inner")),
|
|
458
|
+
over=("p", "sink", "n"),
|
|
459
|
+
))
|
|
460
|
+
rhs_dyn = pieces[0]
|
|
461
|
+
for piece in pieces[1:]:
|
|
462
|
+
rhs_dyn = rhs_dyn + piece
|
|
463
|
+
rhs_terms["dynamic_flow"] = rhs_dyn
|
|
464
|
+
|
|
465
|
+
# demand term: pdtReserve_reservation is the reservation timeseries.
|
|
466
|
+
# The .mod's dynamic_eq RHS does *not* include pdtReserve_reservation
|
|
467
|
+
# directly — the demand is dynamic. However the LHS keeps the
|
|
468
|
+
# vq_reserve · pdtReserve_reservation term. When the dynamic RHS
|
|
469
|
+
# is unavailable in V1 we fall back to a 0 RHS, matching the .mod
|
|
470
|
+
# in the empty-set degenerate case.
|
|
471
|
+
if not rhs_terms:
|
|
472
|
+
rhs_terms = {"zero": 0.0}
|
|
473
|
+
|
|
474
|
+
lhs_terms = {"vq_term": Where(lhs_vq, dyn_rug)}
|
|
475
|
+
if lhs_reserve_core is not None:
|
|
476
|
+
lhs_terms["reserve_sum"] = Where(lhs_reserve_core, dyn_rug)
|
|
477
|
+
m.add_cstr(
|
|
478
|
+
"reserveBalance_dynamic_eq",
|
|
479
|
+
over = dyn_dt,
|
|
480
|
+
sense = ">=",
|
|
481
|
+
lhs_terms = lhs_terms,
|
|
482
|
+
rhs_terms = rhs_terms,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# ── reserveBalance_up_n_1_eq / reserveBalance_down_n_1_eq ──────────
|
|
486
|
+
# The n-1 family adds an extra index ``p_n_1 in process_large_failure``
|
|
487
|
+
# to the constraint domain — one row per (failing process × group ×
|
|
488
|
+
# d × t). V1 simplification: emit per-(r, ud, g, d, t) only, with the
|
|
489
|
+
# large-failure RHS summed across all p_n_1 candidates. The .mod
|
|
490
|
+
# imposes the constraint *for each* p_n_1 separately, which is tighter
|
|
491
|
+
# in pathological cases. Listed in manifest TODO.
|
|
492
|
+
method_n1 = d.reserve_upDown_group_method_n_1
|
|
493
|
+
if method_n1 is not None and method_n1.height > 0:
|
|
494
|
+
for ud_filter, name in [
|
|
495
|
+
("up", "reserveBalance_up_n_1_eq"),
|
|
496
|
+
("down", "reserveBalance_down_n_1_eq"),
|
|
497
|
+
]:
|
|
498
|
+
n1_rug = method_n1.filter(pl.col("ud") == ud_filter) \
|
|
499
|
+
.select("r", "ud", "g").unique()
|
|
500
|
+
if n1_rug.height == 0:
|
|
501
|
+
continue
|
|
502
|
+
n1_dt = n1_rug.join(d.dt, how="cross")
|
|
503
|
+
rhs_terms: dict = {}
|
|
504
|
+
v_flow = vars.get("v_flow")
|
|
505
|
+
lfr_set = getattr(d, "process_reserve_upDown_node_large_failure_ratio", None)
|
|
506
|
+
lfr_param = getattr(d, "p_process_reserve_upDown_node_large_failure_ratio_value", None)
|
|
507
|
+
if (v_flow is not None and lfr_set is not None and lfr_set.height > 0
|
|
508
|
+
and lfr_param is not None and d.process_source_sink is not None):
|
|
509
|
+
lfr_g = (lfr_set.join(gn, on="n", how="inner")
|
|
510
|
+
.join(n1_rug, on=["r", "ud", "g"], how="inner")
|
|
511
|
+
.select("p", "r", "ud", "n", "g"))
|
|
512
|
+
if lfr_g.height > 0:
|
|
513
|
+
pss = d.process_source_sink
|
|
514
|
+
if ud_filter == "up":
|
|
515
|
+
# n is the failing-process sink (delivering side).
|
|
516
|
+
# Relabel v_flow's ``sink`` axis to ``n`` so it ties
|
|
517
|
+
# to the failing node (see _flow_node_view).
|
|
518
|
+
v_flow_n = _flow_node_view(v_flow, "sink")
|
|
519
|
+
pss_idx = pss.pipe(rename_to_axis, {"sink": "n"}).select("p", "source", "n")
|
|
520
|
+
rhs_terms["n_1_failure"] = Sum(
|
|
521
|
+
Where(v_flow_n * p_unitsize * lfr_param,
|
|
522
|
+
pss_idx.join(lfr_g, on=["p", "n"], how="inner")),
|
|
523
|
+
over=("p", "source", "n"),
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
# n is the failing-process source (consuming side).
|
|
527
|
+
# Relabel v_flow's ``source`` axis to ``n``.
|
|
528
|
+
v_flow_n = _flow_node_view(v_flow, "source")
|
|
529
|
+
pss_idx = pss.pipe(rename_to_axis, {"source": "n"}).select("p", "n", "sink")
|
|
530
|
+
rhs_terms["n_1_failure"] = Sum(
|
|
531
|
+
Where(v_flow_n * p_unitsize * lfr_param,
|
|
532
|
+
pss_idx.join(lfr_g, on=["p", "n"], how="inner")),
|
|
533
|
+
over=("p", "sink", "n"),
|
|
534
|
+
)
|
|
535
|
+
if not rhs_terms:
|
|
536
|
+
rhs_terms = {"zero": 0.0}
|
|
537
|
+
|
|
538
|
+
lhs_terms = {"vq_term": Where(lhs_vq, n1_rug)}
|
|
539
|
+
if lhs_reserve_core is not None:
|
|
540
|
+
lhs_terms["reserve_sum"] = Where(lhs_reserve_core, n1_rug)
|
|
541
|
+
m.add_cstr(
|
|
542
|
+
name,
|
|
543
|
+
over = n1_dt,
|
|
544
|
+
sense = ">=",
|
|
545
|
+
lhs_terms = lhs_terms,
|
|
546
|
+
rhs_terms = rhs_terms,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# ── reserve_process_upward / reserve_process_downward ──────────────
|
|
550
|
+
# Per-(p, r, ud, n, d, t):
|
|
551
|
+
# v_reserve · unitsize ≤ max_share · (online · unitsize) [if online]
|
|
552
|
+
# v_reserve · unitsize ≤ max_share · existing-capacity [otherwise]
|
|
553
|
+
#
|
|
554
|
+
# V1: online-not-aware variant. RHS uses max_share · ( existing_count +
|
|
555
|
+
# Σ_{d_inv} v_invest_p[d_inv] - Σ_{d_div} v_divest_p[d_div] ) (the
|
|
556
|
+
# invest/divest terms are moved to the LHS as -invest / +divest).
|
|
557
|
+
# The .mod's online variant requires the online_set + v_online from
|
|
558
|
+
# the online module — that wiring is left to A4.
|
|
559
|
+
max_share = getattr(d, "p_process_reserve_upDown_node_max_share", None)
|
|
560
|
+
if max_share is not None and d.prundt.height > 0:
|
|
561
|
+
existing = getattr(d, "p_process_existing_count", None)
|
|
562
|
+
if existing is not None:
|
|
563
|
+
v_invest_p = vars.get("v_invest_p")
|
|
564
|
+
v_divest_p = vars.get("v_divest_p")
|
|
565
|
+
edd_inv_set = getattr(d, "edd_invest_set", None)
|
|
566
|
+
edd_div_act = getattr(d, "edd_divest_active", None)
|
|
567
|
+
for ud_filter, name in [
|
|
568
|
+
("up", "reserve_process_upward"),
|
|
569
|
+
("down", "reserve_process_downward"),
|
|
570
|
+
]:
|
|
571
|
+
idx = d.prundt.filter(pl.col("ud") == ud_filter)
|
|
572
|
+
if idx.height == 0:
|
|
573
|
+
continue
|
|
574
|
+
# max_share is keyed on (p, r, ud, n). existing_count
|
|
575
|
+
# keyed on (p, d). Together these give RHS dims
|
|
576
|
+
# (p, r, ud, n, d) — broadcast-with-Sum across t at row
|
|
577
|
+
# binding. We emit on idx (which has all six dims).
|
|
578
|
+
rhs_param = max_share * existing
|
|
579
|
+
lhs: dict = {"reserve": v_reserve}
|
|
580
|
+
# Restrict invest-tightening to processes appearing in idx.
|
|
581
|
+
p_in_idx = idx.select("p").unique()
|
|
582
|
+
# invest tightening: -Σ_{d_inv} v_invest_p[d_inv] · max_share
|
|
583
|
+
if (v_invest_p is not None and edd_inv_set is not None
|
|
584
|
+
and edd_inv_set.height > 0):
|
|
585
|
+
edd_p = (edd_inv_set.pipe(rename_to_axis, {"e": "p"})
|
|
586
|
+
.join(p_in_idx, on="p", how="inner"))
|
|
587
|
+
if edd_p.height > 0:
|
|
588
|
+
v_inv_at = Var(
|
|
589
|
+
name=v_invest_p.name + f"__at_{name}",
|
|
590
|
+
dims=("p", "d_invest"),
|
|
591
|
+
frame=v_invest_p.frame.pipe(rename_to_axis, {"d": "d_invest"}),
|
|
592
|
+
lower=v_invest_p.lower, upper=v_invest_p.upper,
|
|
593
|
+
)
|
|
594
|
+
inv_sum = Sum(Where(v_inv_at, edd_p), over=("d_invest",))
|
|
595
|
+
lhs["invest_neg"] = -(inv_sum * max_share)
|
|
596
|
+
# divest tightening: +Σ_{d_div} v_divest_p[d_div] · max_share
|
|
597
|
+
if (v_divest_p is not None and edd_div_act is not None
|
|
598
|
+
and edd_div_act.height > 0):
|
|
599
|
+
edd_p_div = edd_div_act.join(p_in_idx, on="p", how="inner")
|
|
600
|
+
if edd_p_div.height > 0:
|
|
601
|
+
v_div_at = Var(
|
|
602
|
+
name=v_divest_p.name + f"__at_{name}",
|
|
603
|
+
dims=("p", "d_divest"),
|
|
604
|
+
frame=v_divest_p.frame.pipe(rename_to_axis, {"d": "d_divest"}),
|
|
605
|
+
lower=v_divest_p.lower, upper=v_divest_p.upper,
|
|
606
|
+
)
|
|
607
|
+
div_sum = Sum(Where(v_div_at, edd_p_div), over=("d_divest",))
|
|
608
|
+
lhs["divest"] = div_sum * max_share
|
|
609
|
+
m.add_cstr(
|
|
610
|
+
name,
|
|
611
|
+
over = idx,
|
|
612
|
+
sense = "<=",
|
|
613
|
+
lhs_terms = lhs,
|
|
614
|
+
rhs_terms = {"max": rhs_param},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# Objective
|
|
620
|
+
|
|
621
|
+
def add_objective_terms(m, d, vars: dict, op_factor):
|
|
622
|
+
"""Return the ``vq_reserve`` slack penalty Expr to be added to the
|
|
623
|
+
objective. Mirrors objective_audit.md §9.4 / flextool.mod 2100-2101.
|
|
624
|
+
|
|
625
|
+
+ Σ vq_reserve · pdtReserve_reservation · penalty_reserve · op_factor
|
|
626
|
+
|
|
627
|
+
where ``op_factor = step_duration · timestep_weight · inflation_op /
|
|
628
|
+
period_share`` (the same factor used elsewhere in the objective).
|
|
629
|
+
|
|
630
|
+
Returns ``None`` (zero contribution) if the reserve subsystem is not
|
|
631
|
+
active or if any dependency is missing — same pattern as the rest of
|
|
632
|
+
the optional objective terms.
|
|
633
|
+
"""
|
|
634
|
+
if not has_feature(d):
|
|
635
|
+
return None
|
|
636
|
+
if "vq_reserve" not in vars:
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
vq_reserve = vars["vq_reserve"]
|
|
640
|
+
res_param = d.pdtReserve_upDown_group_reservation
|
|
641
|
+
pen = d.p_reserve_upDown_group_penalty_reserve
|
|
642
|
+
if res_param is None or pen is None:
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
# ``op_factor`` carries ``pdt_branch_weight`` when stochastics is
|
|
646
|
+
# active (folded in by the model.py caller — see A6 close). In
|
|
647
|
+
# deterministic single-branch runs ``pdt_branch_weight`` is None and
|
|
648
|
+
# ``op_factor`` is the four-Param product the .mod uses.
|
|
649
|
+
return Sum(vq_reserve * res_param * pen * op_factor)
|