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,699 @@
|
|
|
1
|
+
"""Adder sizing for the adequacy calibrator (C1b) — the numerically
|
|
2
|
+
load-bearing slice.
|
|
3
|
+
|
|
4
|
+
The engine param ``energy_margin_adder`` is a per-timestep MWh value
|
|
5
|
+
SUBTRACTED from a node's inflow in the invest solve, broadcast CONSTANT
|
|
6
|
+
over the invest ``(d, t)`` grid (P1). ``node_slack_up_d_e`` reports the
|
|
7
|
+
resulting unserved energy as **annual MWh**, via
|
|
8
|
+
:func:`flextool.process_outputs._annualize.annualize_dt_to_d`::
|
|
9
|
+
|
|
10
|
+
annual(d) = ( Σ_t value(d, t) · timestep_weight(d, t) ) / period_share(d)
|
|
11
|
+
|
|
12
|
+
A CONSTANT per-timestep adder ``a`` (MWh/step) therefore adds annual demand
|
|
13
|
+
``ΔE = a · W`` where
|
|
14
|
+
|
|
15
|
+
W = Σ_{d ∈ invest periods} ( Σ_t timestep_weight(d, t) ) / period_share(d).
|
|
16
|
+
|
|
17
|
+
So to inject ``X`` MWh of annual demand the scalar adder is ``a = X / W``.
|
|
18
|
+
The calibrator's per-node target is ``X = λ · residual_unserved(node)``, and
|
|
19
|
+
hence ``increment_adder(node) = λ · residual(node) / W``.
|
|
20
|
+
|
|
21
|
+
Why ``W`` and not the ``/n_invest_steps`` shorthand
|
|
22
|
+
---------------------------------------------------
|
|
23
|
+
``/n_invest_steps`` is only correct for a full-year, weight≡1 timeline. For
|
|
24
|
+
the representative-period timelines the calibrator TARGETS, ``period_share``
|
|
25
|
+
is far below 1 (e.g. a 72-step year fraction of ``72/8760``), so the true
|
|
26
|
+
divisor ``W`` is ``8760`` per period, not ``72`` — the naive divisor would
|
|
27
|
+
overshoot the first correction by the rep-weight factor (~121× here).
|
|
28
|
+
|
|
29
|
+
The W source (why it is robust)
|
|
30
|
+
-------------------------------
|
|
31
|
+
``W`` is a fixed property of the scenario's invest timeline, computed ONCE
|
|
32
|
+
per run straight from the input DB by REUSING the engine's own per-solve
|
|
33
|
+
derivations — the same code paths preprocessing uses — never the
|
|
34
|
+
``--csv-dump`` files (``solve_data/*.csv``), which are gated AND overwritten
|
|
35
|
+
by the LAST (dispatch) sub-solve and so do NOT reflect the invest grid:
|
|
36
|
+
|
|
37
|
+
* the invest ``(d, t)`` grid and per-period
|
|
38
|
+
``complete_period_share_of_year`` come from
|
|
39
|
+
:func:`flextool.engine_polars._per_solve_sets.derive_per_solve_aggregates`;
|
|
40
|
+
* ``Σ_t timestep_weight(d, t)`` per period comes from the ACTUAL per-``(d,
|
|
41
|
+
t)`` weights the engine builds, NOT a step-count shortcut —
|
|
42
|
+
:func:`flextool.engine_polars._derived_params.p_timestep_weight_from_source`
|
|
43
|
+
for the default / ``timeset_weights`` regimes, and
|
|
44
|
+
:func:`flextool.engine_polars._emit_solve_writers._compute_rp_frames` for
|
|
45
|
+
``representative_period_weights`` (RP).
|
|
46
|
+
|
|
47
|
+
Why the ACTUAL weight, not the step count ``n_d``
|
|
48
|
+
-------------------------------------------------
|
|
49
|
+
``annualize_dt_to_d`` weights every ``(d, t)`` by ``p_timestep_weight``, and
|
|
50
|
+
RP weights flow into ``p_timestep_weight`` (``_compute_rp_frames`` folds
|
|
51
|
+
``representative_period_weights`` into ``timestep_weight.csv``; the CSV
|
|
52
|
+
loader puts it in ``p_timestep_weight``; ``out_node.py`` annualises
|
|
53
|
+
``node_slack_up_d_e`` with it) — so ``W`` MUST use the same weights. For the
|
|
54
|
+
default and ``timeset_weights`` regimes the weights normalise to ``Σ_t = n_d``
|
|
55
|
+
(the step count), so those reduce to ``n_d``; but a general RP timeset with
|
|
56
|
+
UNEQUAL rep-block lengths or un-normalised weights does NOT, so ``W`` reads
|
|
57
|
+
the real ``Σ_t timestep_weight`` and is correct for every regime.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from collections import defaultdict
|
|
63
|
+
|
|
64
|
+
import polars as pl
|
|
65
|
+
|
|
66
|
+
from flextool.engine_polars._derived_params import (
|
|
67
|
+
p_timestep_weight_from_source,
|
|
68
|
+
)
|
|
69
|
+
from flextool.engine_polars._emit_solve_writers import _compute_rp_frames
|
|
70
|
+
from flextool.engine_polars._per_solve_sets import derive_per_solve_aggregates
|
|
71
|
+
from flextool.engine_polars._solve_config import SolveConfig
|
|
72
|
+
from flextool.engine_polars._spinedb_reader import SpineDbReader
|
|
73
|
+
from flextool.engine_polars._timeline import TimelineConfig
|
|
74
|
+
|
|
75
|
+
# Per-node absolute shed tolerance (MWh). Nodes whose residual unserved
|
|
76
|
+
# energy is at/below this are treated as non-shedding and get NO increment,
|
|
77
|
+
# so numerical dust never seeds a spurious adder.
|
|
78
|
+
_SHED_TOL_MWH = 1e-6
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalise_url(url: str) -> str:
|
|
82
|
+
"""Promote a bare filesystem path to a ``sqlite:///`` URL; pass through
|
|
83
|
+
anything already carrying a ``"://"`` scheme."""
|
|
84
|
+
return url if "://" in url else f"sqlite:///{url}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _invest_solves(sc: "SolveConfig") -> list[str]:
|
|
88
|
+
"""Return the model's top-level solve names — the invest solves.
|
|
89
|
+
|
|
90
|
+
``model.solves`` (``SolveConfig.model_solve``) lists the solves the model
|
|
91
|
+
runs; in a nested-invest model the top-level solve IS the invest solve
|
|
92
|
+
(it CONTAINS the dispatch sub-solves), and in a flat single-solve model
|
|
93
|
+
that one solve is both invest and dispatch. Either way the top-level
|
|
94
|
+
solve carries the ``(d, t)`` grid the ``energy_margin_adder`` is
|
|
95
|
+
broadcast over, which is exactly the grid ``W`` must be measured on.
|
|
96
|
+
"""
|
|
97
|
+
solves: list[str] = []
|
|
98
|
+
for solve_list in sc.model_solve.values():
|
|
99
|
+
for s in solve_list:
|
|
100
|
+
if s not in solves:
|
|
101
|
+
solves.append(s)
|
|
102
|
+
return solves
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def w_from_grids(
|
|
106
|
+
weight_by_period: dict[str, int | float],
|
|
107
|
+
share_by_period: dict[str, float],
|
|
108
|
+
) -> float:
|
|
109
|
+
"""Return ``W = Σ_d weight_d / share_d`` from per-period weight-sums + shares.
|
|
110
|
+
|
|
111
|
+
``weight_by_period[d]`` is the ACTUAL ``Σ_t timestep_weight(d, t)`` and
|
|
112
|
+
``share_by_period[d]`` is ``complete_period_share_of_year(d)``. Pure
|
|
113
|
+
arithmetic — the DB-reading :func:`invest_weight_W` builds the two dicts
|
|
114
|
+
and calls this.
|
|
115
|
+
|
|
116
|
+
Reductions the tests pin: with ``weight ≡ 1`` and ``share ≡ 1``
|
|
117
|
+
(full-year, evenly-sampled) ``W`` collapses to the total step count
|
|
118
|
+
``Σ_d n_d``; with NON-unit weights (a general RP grid) ``W`` uses
|
|
119
|
+
``Σ_t weight``, NOT the step count.
|
|
120
|
+
"""
|
|
121
|
+
total = 0.0
|
|
122
|
+
for d, w in weight_by_period.items():
|
|
123
|
+
share = share_by_period[d]
|
|
124
|
+
if share <= 0.0:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"period {d!r}: non-positive period_share {share!r} — cannot "
|
|
127
|
+
"annualise."
|
|
128
|
+
)
|
|
129
|
+
total += float(w) / float(share)
|
|
130
|
+
return total
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rp_weight_sum_by_period(
|
|
134
|
+
tc: "TimelineConfig", period: str, timeset: str,
|
|
135
|
+
) -> dict[str, float]:
|
|
136
|
+
"""Actual ``Σ_t timestep_weight`` per period for one RP *timeset*.
|
|
137
|
+
|
|
138
|
+
Reuses the engine writer :func:`._emit_solve_writers._compute_rp_frames`
|
|
139
|
+
— the single source of truth that folds
|
|
140
|
+
``representative_period_weights`` into ``timestep_weight.csv`` — so the
|
|
141
|
+
weights match those the annualiser applies to ``node_slack_up_d_e``
|
|
142
|
+
byte-for-byte. The per-``(d, t)`` weight is independent of the RP chain
|
|
143
|
+
TOPOLOGY (``within_solve`` vs ``within_period`` produce identical
|
|
144
|
+
``timestep_weight`` rows; the ``within_period`` writer itself calls this
|
|
145
|
+
same single-timeset path per period), so calling it once per active RP
|
|
146
|
+
timeset and summing is correct for both.
|
|
147
|
+
"""
|
|
148
|
+
timeline_name = tc.timesets__timeline[timeset]
|
|
149
|
+
timeline_steps = [step for step, _dur in tc.timelines[timeline_name]]
|
|
150
|
+
frames = _compute_rp_frames(
|
|
151
|
+
tc.rp_weights[timeset],
|
|
152
|
+
tc.timeset_durations[timeset],
|
|
153
|
+
period,
|
|
154
|
+
timeline_steps,
|
|
155
|
+
)
|
|
156
|
+
tw = frames["timestep_weight.csv"].with_columns(
|
|
157
|
+
pl.col("weight").cast(pl.Float64)
|
|
158
|
+
)
|
|
159
|
+
return {
|
|
160
|
+
str(p): float(w)
|
|
161
|
+
for p, w in tw.group_by("period").agg(pl.col("weight").sum()).iter_rows()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _weight_sum_by_period(
|
|
166
|
+
source: "SpineDbReader",
|
|
167
|
+
sc: "SolveConfig",
|
|
168
|
+
tc: "TimelineConfig",
|
|
169
|
+
solve: str,
|
|
170
|
+
dt_complete: "pl.DataFrame",
|
|
171
|
+
) -> dict[str, float]:
|
|
172
|
+
"""Return ``{period: Σ_t timestep_weight(d, t)}`` for *solve*.
|
|
173
|
+
|
|
174
|
+
Uses the ACTUAL engine-built weights for every regime:
|
|
175
|
+
|
|
176
|
+
* **default / ``timeset_weights``** — the native
|
|
177
|
+
:func:`._derived_params.p_timestep_weight_from_source` (returns dense
|
|
178
|
+
1.0 or the normalised ``timeset_weights``), summed per period;
|
|
179
|
+
* **``representative_period_weights`` (RP)** — that helper returns
|
|
180
|
+
``None`` (RP weights live in the CSV the writer folds), so each active
|
|
181
|
+
RP timeset is folded via :func:`_rp_weight_sum_by_period`. Any non-RP
|
|
182
|
+
period sharing an RP solve falls back to its step count ``n_d`` (which
|
|
183
|
+
the non-RP normalisation makes equal to ``Σ_t weight``).
|
|
184
|
+
"""
|
|
185
|
+
active = sc.timesets_used_by_solves.get(solve, [])
|
|
186
|
+
rp_present = any(ts in tc.rp_weights for _period, ts in active)
|
|
187
|
+
|
|
188
|
+
if not rp_present:
|
|
189
|
+
param = p_timestep_weight_from_source(source, dt_complete, solve)
|
|
190
|
+
if param is not None and param.frame.height > 0:
|
|
191
|
+
agg = param.frame.group_by("d").agg(pl.col("value").sum())
|
|
192
|
+
return {str(d): float(v) for d, v in agg.iter_rows()}
|
|
193
|
+
# No period_timeset / weights resolvable → default 1.0 ⇒ Σ = n_d.
|
|
194
|
+
return {
|
|
195
|
+
str(d): float(n)
|
|
196
|
+
for d, n in dt_complete.group_by("d").len().iter_rows()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# At least one active timeset is RP.
|
|
200
|
+
n_by_d = {
|
|
201
|
+
str(d): float(n)
|
|
202
|
+
for d, n in dt_complete.group_by("d").len().iter_rows()
|
|
203
|
+
}
|
|
204
|
+
out: dict[str, float] = {}
|
|
205
|
+
for period, ts in active:
|
|
206
|
+
period = str(period)
|
|
207
|
+
if ts in tc.rp_weights:
|
|
208
|
+
for p, w in _rp_weight_sum_by_period(tc, period, ts).items():
|
|
209
|
+
out[p] = out.get(p, 0.0) + w
|
|
210
|
+
else:
|
|
211
|
+
out[period] = out.get(period, 0.0) + n_by_d.get(period, 0.0)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def invest_weight_W(url: str, scenario: str) -> float:
|
|
216
|
+
"""Compute the annualisation weight ``W`` for *scenario*'s invest solve.
|
|
217
|
+
|
|
218
|
+
``W = Σ_{d ∈ invest periods} (Σ_t timestep_weight(d, t)) / period_share(d)``
|
|
219
|
+
using the ACTUAL per-``(d, t)`` ``timestep_weight`` the engine builds
|
|
220
|
+
(default / ``timeset_weights`` / ``representative_period_weights``), so a
|
|
221
|
+
constant per-timestep adder ``a`` injects annual demand ``a · W`` for
|
|
222
|
+
every weighting regime; see the module docstring.
|
|
223
|
+
|
|
224
|
+
Computed ONCE per calibration run (``W`` is independent of the adder).
|
|
225
|
+
Reuses the engine's own per-solve derivations so it is correct-by-
|
|
226
|
+
construction against the running engine version, never the gated /
|
|
227
|
+
dispatch-overwritten ``solve_data`` CSVs.
|
|
228
|
+
|
|
229
|
+
Raises
|
|
230
|
+
------
|
|
231
|
+
ValueError
|
|
232
|
+
If no invest solve / grid can be resolved, or ``W`` is non-positive.
|
|
233
|
+
"""
|
|
234
|
+
url = _normalise_url(url)
|
|
235
|
+
sc = SolveConfig.load_from_db_url(url, scenario)
|
|
236
|
+
invest_solves = _invest_solves(sc)
|
|
237
|
+
if not invest_solves:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"scenario {scenario!r}: no model.solves — cannot resolve the "
|
|
240
|
+
"invest solve to size the adder against."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
source = SpineDbReader(url, scenario)
|
|
244
|
+
tc = TimelineConfig.load_from_db_url(url, scenario)
|
|
245
|
+
|
|
246
|
+
total = 0.0
|
|
247
|
+
for solve in invest_solves:
|
|
248
|
+
agg = derive_per_solve_aggregates(source, solve)
|
|
249
|
+
if agg is None:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"scenario {scenario!r}, solve {solve!r}: could not derive the "
|
|
252
|
+
"per-solve (d, t) grid / period share from the DB "
|
|
253
|
+
"(missing solve.period_timeset / timeline.timestep_duration). "
|
|
254
|
+
"W cannot be computed."
|
|
255
|
+
)
|
|
256
|
+
# Σ_t timestep_weight per period (actual engine weights, all regimes).
|
|
257
|
+
weight_by_d = _weight_sum_by_period(source, sc, tc, solve, agg.dt_complete)
|
|
258
|
+
share_by_d = {
|
|
259
|
+
str(d): float(v)
|
|
260
|
+
for d, v in agg.complete_period_share_of_year.select(
|
|
261
|
+
"d", "value"
|
|
262
|
+
).iter_rows()
|
|
263
|
+
}
|
|
264
|
+
# Restrict to the periods that actually have a share (the grid).
|
|
265
|
+
weight_by_d = {d: weight_by_d[d] for d in share_by_d if d in weight_by_d}
|
|
266
|
+
total += w_from_grids(weight_by_d, share_by_d)
|
|
267
|
+
|
|
268
|
+
if total <= 0.0:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"scenario {scenario!r}: computed W={total!r} is non-positive."
|
|
271
|
+
)
|
|
272
|
+
return total
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def scalar_adder(residual_mwh: float, W: float, lam: float) -> float:
|
|
276
|
+
"""Return the per-timestep adder that injects ``lam · residual`` annual MWh.
|
|
277
|
+
|
|
278
|
+
``a = lam · residual_mwh / W`` (the exact inverse of ``ΔE = a · W``).
|
|
279
|
+
``W`` must be positive (a valid invest-timeline annualiser).
|
|
280
|
+
"""
|
|
281
|
+
if W <= 0.0:
|
|
282
|
+
raise ValueError(f"W must be positive, got {W!r}")
|
|
283
|
+
return lam * residual_mwh / W
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def sized_increments(
|
|
287
|
+
residual: dict[str, float],
|
|
288
|
+
*,
|
|
289
|
+
W: float,
|
|
290
|
+
lam: float,
|
|
291
|
+
overshoot: float = 1.0,
|
|
292
|
+
tol: float = _SHED_TOL_MWH,
|
|
293
|
+
) -> dict[str, float]:
|
|
294
|
+
"""Return ``{node: adder_increment}`` for every SHEDDING node.
|
|
295
|
+
|
|
296
|
+
A node is shedding when its residual unserved energy exceeds *tol*;
|
|
297
|
+
non-shedding nodes are skipped entirely (no key), so the loop bumps only
|
|
298
|
+
the nodes that are actually short. Each increment is
|
|
299
|
+
``overshoot · scalar_adder(residual[node], W, lam)`` — the constant
|
|
300
|
+
per-timestep adder that (undamped, ``lam=1``, ``overshoot=1``) would
|
|
301
|
+
inject exactly that node's residual annual MWh back as demand.
|
|
302
|
+
|
|
303
|
+
``overshoot`` (default ``1.0`` = off) is a ``>1`` planning-margin SAFETY
|
|
304
|
+
multiplier: a single-year (or single-year RP) solve under-estimates true
|
|
305
|
+
multi-year severity, so ``overshoot`` deliberately provisions beyond the
|
|
306
|
+
measured slack (``overshoot=1.2`` ⇒ ~20 % extra headroom). The right
|
|
307
|
+
value is MODEL-DEPENDENT.
|
|
308
|
+
"""
|
|
309
|
+
if W <= 0.0:
|
|
310
|
+
raise ValueError(f"W must be positive, got {W!r}")
|
|
311
|
+
out: dict[str, float] = {}
|
|
312
|
+
for node, res in residual.items():
|
|
313
|
+
if res > tol:
|
|
314
|
+
out[node] = overshoot * scalar_adder(res, W, lam)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ===========================================================================
|
|
319
|
+
# T2 — the ``timed`` sizer: place the additive margin at the low-VRE stress
|
|
320
|
+
# hours (per-timestep) instead of spreading it flat.
|
|
321
|
+
# ===========================================================================
|
|
322
|
+
#
|
|
323
|
+
# The uniform sizer above injects ``ΔE = λ·residual`` annual MWh as a CONSTANT
|
|
324
|
+
# per-timestep adder ``a = λ·residual/W``. The ``timed`` sizer injects the
|
|
325
|
+
# SAME total energy but distributes it by the stress SHAPE — the per-cell
|
|
326
|
+
# ``node_slack_up_dt_e`` profile — so the demand lands exactly at the hours the
|
|
327
|
+
# invest solve could not serve.
|
|
328
|
+
#
|
|
329
|
+
# Timeline fold (base → representative)
|
|
330
|
+
# -------------------------------------
|
|
331
|
+
# ``node_slack_up_dt_e`` is the invest-solve up-slack UNFOLDED onto the full
|
|
332
|
+
# base timeline. For a representative-period (RP) timeset every base block
|
|
333
|
+
# ``b`` decomposes convexly over representative blocks ``rep`` with hull weights
|
|
334
|
+
# ``rp_weights[timeset][base_start][rep_start]`` (``Σ_rep weight(b→rep)=1`` per
|
|
335
|
+
# base block — the SAME source ``invest_weight_W`` reuses). For a real hour =
|
|
336
|
+
# (base block ``b``, within-block offset ``h``) carrying slack ``e(b, h)`` we
|
|
337
|
+
# fold it back onto the representative cell that shares its offset::
|
|
338
|
+
#
|
|
339
|
+
# slack_rep(rep, h) = Σ_b weight(b→rep) · e(b, h)
|
|
340
|
+
#
|
|
341
|
+
# Total is conserved: ``Σ_rep slack_rep = Σ_b e(b,·) = residual`` (because
|
|
342
|
+
# ``Σ_rep weight(b→rep)=1``), so the folded stress carries the node's full
|
|
343
|
+
# residual — never the ~15 % that a raw subset of the invest-grid timestamps
|
|
344
|
+
# would (those base rows are convex combinations that sum to a fraction of the
|
|
345
|
+
# residual).
|
|
346
|
+
#
|
|
347
|
+
# Sizing (SHAPE from the fold, MAGNITUDE from the true annual residual)
|
|
348
|
+
# ---------------------------------------------------------------------
|
|
349
|
+
# The fold gives the per-cell stress SHAPE, but its raw magnitude is NOT a
|
|
350
|
+
# reliable proxy for the node's annual residual: ``node_slack_up_dt_e`` equals
|
|
351
|
+
# the annual ``node_slack_up_d_e`` only when the dt table is UNFOLDED onto the
|
|
352
|
+
# full base timeline (as it is for the H2 model, dt≈annual). On a model whose
|
|
353
|
+
# dt table stays on the REPRESENTATIVE grid, ``Σ_dt slack ≠ annual residual``
|
|
354
|
+
# (measured ~0.10 on one RP model), so folding the raw dt total would
|
|
355
|
+
# under-inject ~10×. We therefore use the fold only for the shape and
|
|
356
|
+
# NORMALISE it to the true annual residual ``res = node_slack_up_d_e[node]``.
|
|
357
|
+
#
|
|
358
|
+
# With the ENGINE per-cell timestep weight ``tw_rep(rep, h)`` (from
|
|
359
|
+
# ``_compute_rp_frames``'s ``timestep_weight.csv`` — the same weights the
|
|
360
|
+
# annualiser applies) and the folded shape ``slack_rep(rep, h)`` summing to
|
|
361
|
+
# ``S = Σ_cell slack_rep`` over the cells we can inject on::
|
|
362
|
+
#
|
|
363
|
+
# adder(rep, h) = overshoot · λ · res · (slack_rep(rep, h) / S) / tw_rep(rep, h)
|
|
364
|
+
#
|
|
365
|
+
# The annualised injected energy is then EXACT and independent of both the
|
|
366
|
+
# dt-table form and ``tw_rep`` (it CANCELS in the weighted sum)::
|
|
367
|
+
#
|
|
368
|
+
# Σ_cell adder·tw_rep = overshoot · λ · res · (Σ_cell slack_rep / S)
|
|
369
|
+
# = overshoot · λ · res
|
|
370
|
+
#
|
|
371
|
+
# — the SAME total energy the uniform sizer injects (``overshoot·λ·res``),
|
|
372
|
+
# placed at the stressed cells, correct whether the dt table is pre-unfolded
|
|
373
|
+
# (``S == res`` ⇒ ``adder = overshoot·λ·slack_rep/tw_rep``, unchanged from the
|
|
374
|
+
# naive fold) or on the representative grid (``S ≠ res`` ⇒ the shape is scaled
|
|
375
|
+
# up to the annual magnitude). ``λ`` is ``damping_first`` on the first
|
|
376
|
+
# correction else ``damping_remaining`` (identical to uniform); ``overshoot``
|
|
377
|
+
# is the same ``>1`` planning-margin safety multiplier the uniform sizer uses.
|
|
378
|
+
#
|
|
379
|
+
# Non-RP invest solve
|
|
380
|
+
# -------------------
|
|
381
|
+
# When the invest solve is NOT representative-period the base timeline IS the
|
|
382
|
+
# invest grid; the fold degenerates to identity (each cell maps to itself with
|
|
383
|
+
# weight 1) and ``tw_rep`` is the per-cell ``p_timestep_weight`` (dense 1.0 or
|
|
384
|
+
# the normalised ``timeset_weights``). The formula then places the adder at
|
|
385
|
+
# each timestep in proportion to that timestep's own slack, normalised to the
|
|
386
|
+
# annual residual — the sensible single-representative reduction.
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def size_timed(
|
|
390
|
+
dt_slack: dict[str, dict[tuple[str, str], float]],
|
|
391
|
+
residual: dict[str, float],
|
|
392
|
+
fold_edges: list[tuple[str, str, str, float]],
|
|
393
|
+
tw_rep: dict[tuple[str, str], float],
|
|
394
|
+
*,
|
|
395
|
+
lam: float,
|
|
396
|
+
overshoot: float = 1.0,
|
|
397
|
+
tol: float = _SHED_TOL_MWH,
|
|
398
|
+
) -> dict[str, dict[tuple[str, str], float]]:
|
|
399
|
+
"""Pure timed sizer — fold + normalised per-cell sizing, no DB / no solver.
|
|
400
|
+
|
|
401
|
+
The fold supplies only the per-cell stress SHAPE; the injected MAGNITUDE
|
|
402
|
+
is taken from the TRUE annual residual *residual[node]* (see the module's
|
|
403
|
+
T2 comment). Each shedding node's folded shape is normalised so the
|
|
404
|
+
annualised injected energy equals ``overshoot · λ · residual[node]`` —
|
|
405
|
+
correct whether ``dt_slack`` was pre-unfolded onto the base timeline
|
|
406
|
+
(``Σ shape == residual``, so the per-cell adder is unchanged from the
|
|
407
|
+
naive fold) or left on the representative grid (``Σ shape ≠ residual``, so
|
|
408
|
+
the shape is scaled to the annual magnitude).
|
|
409
|
+
|
|
410
|
+
Parameters
|
|
411
|
+
----------
|
|
412
|
+
dt_slack:
|
|
413
|
+
``{node: {(period, time): slack}}`` — the base-timeline up-slack
|
|
414
|
+
profile (``read_residual_unserved_dt``, converted to a lookup). Used
|
|
415
|
+
for SHAPE only; its total need not equal the annual residual.
|
|
416
|
+
residual:
|
|
417
|
+
``{node: annual_MWh}`` — the per-node TRUE annual residual
|
|
418
|
+
(``node_slack_up_d_e``); a node is SHEDDING (and thus sized) only when
|
|
419
|
+
it exceeds *tol*, matching the uniform :func:`sized_increments` gate,
|
|
420
|
+
and it also sets the injected magnitude the shape is normalised to.
|
|
421
|
+
fold_edges:
|
|
422
|
+
``[(period, base_time, rep_time, weight), ...]`` — the sparse fold
|
|
423
|
+
operator: each edge sends ``weight · e(period, base_time)`` onto the
|
|
424
|
+
representative cell ``(period, rep_time)``. For a total-conserving
|
|
425
|
+
fold every base cell's out-edge weights sum to 1.
|
|
426
|
+
tw_rep:
|
|
427
|
+
``{(period, rep_time): timestep_weight}`` — the engine per-cell weight
|
|
428
|
+
the adder is divided by (and the annualiser multiplies back).
|
|
429
|
+
lam:
|
|
430
|
+
Damping factor λ.
|
|
431
|
+
overshoot:
|
|
432
|
+
Planning-margin safety multiplier (default ``1.0`` = off); ``>1``
|
|
433
|
+
provisions beyond the measured slack, exactly as in the uniform sizer.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
``{node: {(period, rep_time): adder}}`` for every shedding node. Cells
|
|
438
|
+
with zero folded slack (or a non-positive ``tw_rep``) are omitted, so the
|
|
439
|
+
map carries only the stressed representative cells, and
|
|
440
|
+
``Σ_cell adder·tw_rep == overshoot · λ · residual[node]`` over the kept
|
|
441
|
+
cells.
|
|
442
|
+
"""
|
|
443
|
+
out: dict[str, dict[tuple[str, str], float]] = {}
|
|
444
|
+
for node, res in residual.items():
|
|
445
|
+
if res <= tol:
|
|
446
|
+
continue
|
|
447
|
+
node_slack = dt_slack.get(node)
|
|
448
|
+
if not node_slack:
|
|
449
|
+
continue
|
|
450
|
+
# Fold the base-timeline slack onto the representative cells → the
|
|
451
|
+
# per-cell stress SHAPE (its raw magnitude is unreliable; see below).
|
|
452
|
+
slack_rep: dict[tuple[str, str], float] = defaultdict(float)
|
|
453
|
+
for period, base_time, rep_time, weight in fold_edges:
|
|
454
|
+
e = node_slack.get((period, base_time))
|
|
455
|
+
if e:
|
|
456
|
+
slack_rep[(period, rep_time)] += weight * e
|
|
457
|
+
# Keep only the cells we can actually inject on (positive folded slack,
|
|
458
|
+
# positive engine weight) BEFORE normalising, so the annualised
|
|
459
|
+
# injected total is EXACTLY overshoot·λ·res over the kept cells.
|
|
460
|
+
valid: dict[tuple[str, str], tuple[float, float]] = {}
|
|
461
|
+
for cell, sr in slack_rep.items():
|
|
462
|
+
if sr <= 0.0:
|
|
463
|
+
continue
|
|
464
|
+
tw = tw_rep.get(cell)
|
|
465
|
+
if tw is None or tw <= 0.0:
|
|
466
|
+
continue
|
|
467
|
+
valid[cell] = (sr, tw)
|
|
468
|
+
shape_total = sum(sr for sr, _tw in valid.values())
|
|
469
|
+
if shape_total <= 0.0:
|
|
470
|
+
continue
|
|
471
|
+
# NORMALISE the shape to the true annual residual: distribute
|
|
472
|
+
# overshoot·λ·res over the cells by their shape fraction, then divide
|
|
473
|
+
# by tw_rep so the annualiser recovers exactly that energy.
|
|
474
|
+
scale = overshoot * lam * res / shape_total
|
|
475
|
+
adder = {cell: scale * sr / tw for cell, (sr, tw) in valid.items()}
|
|
476
|
+
if adder:
|
|
477
|
+
out[node] = adder
|
|
478
|
+
return out
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _timeline_steps_and_index(
|
|
482
|
+
tc: "TimelineConfig", timeset: str,
|
|
483
|
+
) -> tuple[list[str], dict[str, int]]:
|
|
484
|
+
"""Ordered timeline steps + ``{step: idx}`` for *timeset*'s timeline."""
|
|
485
|
+
timeline_name = tc.timesets__timeline[timeset]
|
|
486
|
+
steps = [step for step, _dur in tc.timelines[timeline_name]]
|
|
487
|
+
return steps, {s: i for i, s in enumerate(steps)}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _rp_fold_edges_and_tw(
|
|
491
|
+
tc: "TimelineConfig", period: str, timeset: str,
|
|
492
|
+
) -> tuple[list[tuple[str, str, str, float]], dict[tuple[str, str], float]]:
|
|
493
|
+
"""Build the RP fold edges + per-cell ``tw_rep`` for one (period, timeset).
|
|
494
|
+
|
|
495
|
+
``edges`` maps every base-timeline cell ``(period, base_time)`` onto the
|
|
496
|
+
representative cell ``(period, rep_time)`` that shares its within-block
|
|
497
|
+
offset ``h``, weighted by the hull weight ``rp_weights[base][rep]``.
|
|
498
|
+
``tw_rep`` comes straight from the engine writer's ``timestep_weight.csv``
|
|
499
|
+
(:func:`._emit_solve_writers._compute_rp_frames`) so the sizer divides by
|
|
500
|
+
exactly the weight the annualiser multiplies back.
|
|
501
|
+
|
|
502
|
+
Two representative-period regimes are handled:
|
|
503
|
+
|
|
504
|
+
* **Hull / equal-length blocks** (the ``timed`` sizer's target, e.g.
|
|
505
|
+
``hull_5rp_168h``): many base blocks each the SAME length as the
|
|
506
|
+
representative blocks, and ``node_slack_up_dt_e`` is unfolded onto the
|
|
507
|
+
FULL base timeline. The offset-preserving fold above applies.
|
|
508
|
+
* **``representative_period_weights``**: a base period REPRESENTED by a
|
|
509
|
+
few weighted sub-blocks (base-block length ≫ rep-block length), where the
|
|
510
|
+
invest slack already lives on the representative grid. There is nothing
|
|
511
|
+
to unfold — the fold degenerates to identity on the representative cells
|
|
512
|
+
(each rep cell maps to itself, weight 1), which the total-conservation
|
|
513
|
+
contract (Σ out-weight per source cell = 1) still satisfies.
|
|
514
|
+
|
|
515
|
+
The regime is decided by block-length alignment; a mismatch is NOT an
|
|
516
|
+
error (it is the second regime), so no valid RP model is ever rejected.
|
|
517
|
+
"""
|
|
518
|
+
steps, idx = _timeline_steps_and_index(tc, timeset)
|
|
519
|
+
rpw = tc.rp_weights[timeset] # {base_start: {rep_start: weight}}
|
|
520
|
+
|
|
521
|
+
# Representative block ranges, anchored to real timeline steps.
|
|
522
|
+
rep_count: dict[str, int] = {}
|
|
523
|
+
for start, count in tc.timeset_durations[timeset]:
|
|
524
|
+
rep_count[str(start)] = int(float(count))
|
|
525
|
+
|
|
526
|
+
frames = _compute_rp_frames(
|
|
527
|
+
rpw, tc.timeset_durations[timeset], period, steps,
|
|
528
|
+
)
|
|
529
|
+
tw = frames["timestep_weight.csv"].with_columns(
|
|
530
|
+
pl.col("weight").cast(pl.Float64)
|
|
531
|
+
)
|
|
532
|
+
tw_rep = {(str(p), str(t)): float(w) for p, t, w in tw.iter_rows()}
|
|
533
|
+
|
|
534
|
+
# Base blocks TILE the timeline: sort the rp_weights base starts by
|
|
535
|
+
# timeline position; each base block spans from its start to the next
|
|
536
|
+
# base start (the last runs to the timeline end). Deriving the length
|
|
537
|
+
# from the tiling handles a short trailing block.
|
|
538
|
+
base_starts = sorted(rpw.keys(), key=lambda s: idx.get(s, len(steps)))
|
|
539
|
+
base_ranges: list[tuple[str, int, int]] = [] # (base_start, start_idx, count)
|
|
540
|
+
for i, bs in enumerate(base_starts):
|
|
541
|
+
if bs not in idx:
|
|
542
|
+
raise ValueError(
|
|
543
|
+
f"RP fold: base block start {bs!r} not in timeline "
|
|
544
|
+
f"(period={period!r}, timeset={timeset!r})."
|
|
545
|
+
)
|
|
546
|
+
bstart_idx = idx[bs]
|
|
547
|
+
bend_idx = idx[base_starts[i + 1]] if i + 1 < len(base_starts) else len(steps)
|
|
548
|
+
base_ranges.append((bs, bstart_idx, bend_idx - bstart_idx))
|
|
549
|
+
|
|
550
|
+
# Aligned iff every base block is no longer than every rep block it maps to
|
|
551
|
+
# (offset h has a representative counterpart). Otherwise this is the
|
|
552
|
+
# representative_period_weights regime → identity fold on the rep grid.
|
|
553
|
+
aligned = all(
|
|
554
|
+
bcount <= rep_count.get(str(rep_start), 0)
|
|
555
|
+
for _bs, _si, bcount in base_ranges
|
|
556
|
+
for rep_start, w in rpw[_bs].items()
|
|
557
|
+
if w > 1e-12
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if not aligned:
|
|
561
|
+
# Identity on the representative cells: the invest slack is already
|
|
562
|
+
# representative, so each rep cell maps to itself with weight 1.
|
|
563
|
+
edges = [(str(p), str(t), str(t), 1.0) for (p, t) in tw_rep]
|
|
564
|
+
return edges, tw_rep
|
|
565
|
+
|
|
566
|
+
edges: list[tuple[str, str, str, float]] = []
|
|
567
|
+
for bs, bstart_idx, bcount in base_ranges:
|
|
568
|
+
for rep_start, weight in rpw[bs].items():
|
|
569
|
+
if weight <= 1e-12:
|
|
570
|
+
continue
|
|
571
|
+
r_idx = idx.get(rep_start)
|
|
572
|
+
if r_idx is None:
|
|
573
|
+
raise ValueError(
|
|
574
|
+
f"RP fold: representative block start {rep_start!r} not in "
|
|
575
|
+
f"timeline (period={period!r}, timeset={timeset!r})."
|
|
576
|
+
)
|
|
577
|
+
for h in range(bcount):
|
|
578
|
+
edges.append(
|
|
579
|
+
(period, steps[bstart_idx + h], steps[r_idx + h], float(weight))
|
|
580
|
+
)
|
|
581
|
+
return edges, tw_rep
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _identity_edges_and_tw(
|
|
585
|
+
source: "SpineDbReader",
|
|
586
|
+
sc: "SolveConfig",
|
|
587
|
+
solve: str,
|
|
588
|
+
dt_complete: "pl.DataFrame",
|
|
589
|
+
periods: set[str] | None = None,
|
|
590
|
+
) -> tuple[list[tuple[str, str, str, float]], dict[tuple[str, str], float]]:
|
|
591
|
+
"""Identity fold (non-RP): each invest cell maps to itself, weight 1.
|
|
592
|
+
|
|
593
|
+
``tw_rep`` is the per-cell ``p_timestep_weight`` (dense 1.0 or the
|
|
594
|
+
normalised ``timeset_weights``); *periods*, when given, restricts the grid
|
|
595
|
+
to the non-RP periods of a mixed solve.
|
|
596
|
+
"""
|
|
597
|
+
grid = dt_complete
|
|
598
|
+
if periods is not None:
|
|
599
|
+
grid = grid.filter(pl.col("d").cast(pl.Utf8).is_in(list(periods)))
|
|
600
|
+
edges = [
|
|
601
|
+
(str(d), str(t), str(t), 1.0)
|
|
602
|
+
for d, t in grid.select("d", "t").iter_rows()
|
|
603
|
+
]
|
|
604
|
+
param = p_timestep_weight_from_source(source, dt_complete, solve)
|
|
605
|
+
tw_rep: dict[tuple[str, str], float] = {}
|
|
606
|
+
if param is not None and param.frame.height > 0:
|
|
607
|
+
for d, t, v in param.frame.select("d", "t", "value").iter_rows():
|
|
608
|
+
tw_rep[(str(d), str(t))] = float(v)
|
|
609
|
+
# Any cell without an explicit weight defaults to the trivial 1.0.
|
|
610
|
+
for period, base_time, _rt, _w in edges:
|
|
611
|
+
tw_rep.setdefault((period, base_time), 1.0)
|
|
612
|
+
return edges, tw_rep
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def timed_increments(
|
|
616
|
+
residual: dict[str, float],
|
|
617
|
+
dt_slack: dict[str, dict[tuple[str, str], float]],
|
|
618
|
+
url: str,
|
|
619
|
+
scenario: str,
|
|
620
|
+
*,
|
|
621
|
+
lam: float,
|
|
622
|
+
overshoot: float = 1.0,
|
|
623
|
+
tol: float = _SHED_TOL_MWH,
|
|
624
|
+
) -> dict[str, dict[tuple[str, str], float]]:
|
|
625
|
+
"""Return ``{node: {(period, time): adder}}`` for every SHEDDING node.
|
|
626
|
+
|
|
627
|
+
Assembles the RP (or identity) fold for *scenario*'s invest solve(s) from
|
|
628
|
+
the DB and applies the pure :func:`size_timed`. Each shedding node's
|
|
629
|
+
base-timeline slack supplies the stress SHAPE, normalised to the node's
|
|
630
|
+
TRUE annual residual and converted to a per-cell ``energy_margin_adder``
|
|
631
|
+
that (weighted by ``tw_rep``) injects exactly ``overshoot · λ · residual``
|
|
632
|
+
annual MWh — the same total as uniform, placed at the stressed hours.
|
|
633
|
+
``overshoot`` (default ``1.0``) is the planning-margin safety multiplier.
|
|
634
|
+
|
|
635
|
+
The fold reuses the engine's own rep-weight machinery
|
|
636
|
+
(``rp_weights`` + ``_compute_rp_frames``), never the on-disk
|
|
637
|
+
``timeline_matching_map.csv`` (the wrong, unreliable artifact).
|
|
638
|
+
"""
|
|
639
|
+
url = _normalise_url(url)
|
|
640
|
+
sc = SolveConfig.load_from_db_url(url, scenario)
|
|
641
|
+
tc = TimelineConfig.load_from_db_url(url, scenario)
|
|
642
|
+
source = SpineDbReader(url, scenario)
|
|
643
|
+
invest_solves = _invest_solves(sc)
|
|
644
|
+
if not invest_solves:
|
|
645
|
+
raise ValueError(
|
|
646
|
+
f"scenario {scenario!r}: no model.solves — cannot resolve the "
|
|
647
|
+
"invest solve for timed sizing."
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
all_edges: list[tuple[str, str, str, float]] = []
|
|
651
|
+
tw_rep: dict[tuple[str, str], float] = {}
|
|
652
|
+
for solve in invest_solves:
|
|
653
|
+
active = sc.timesets_used_by_solves.get(solve, [])
|
|
654
|
+
rp_pairs = [(str(p), ts) for p, ts in active if ts in tc.rp_weights]
|
|
655
|
+
nonrp_periods = {str(p) for p, ts in active if ts not in tc.rp_weights}
|
|
656
|
+
if rp_pairs:
|
|
657
|
+
for period, ts in rp_pairs:
|
|
658
|
+
edges, tw = _rp_fold_edges_and_tw(tc, period, ts)
|
|
659
|
+
all_edges.extend(edges)
|
|
660
|
+
tw_rep.update(tw)
|
|
661
|
+
# Mixed solve: any non-RP period folds through the identity path.
|
|
662
|
+
if nonrp_periods:
|
|
663
|
+
agg = derive_per_solve_aggregates(source, solve)
|
|
664
|
+
if agg is not None:
|
|
665
|
+
edges, tw = _identity_edges_and_tw(
|
|
666
|
+
source, sc, solve, agg.dt_complete, nonrp_periods,
|
|
667
|
+
)
|
|
668
|
+
all_edges.extend(edges)
|
|
669
|
+
tw_rep.update(tw)
|
|
670
|
+
else:
|
|
671
|
+
agg = derive_per_solve_aggregates(source, solve)
|
|
672
|
+
if agg is None:
|
|
673
|
+
raise ValueError(
|
|
674
|
+
f"scenario {scenario!r}, solve {solve!r}: could not derive "
|
|
675
|
+
"the invest (d, t) grid for identity-fold timed sizing."
|
|
676
|
+
)
|
|
677
|
+
edges, tw = _identity_edges_and_tw(source, sc, solve, agg.dt_complete)
|
|
678
|
+
all_edges.extend(edges)
|
|
679
|
+
tw_rep.update(tw)
|
|
680
|
+
|
|
681
|
+
if not all_edges:
|
|
682
|
+
raise ValueError(
|
|
683
|
+
f"scenario {scenario!r}: timed sizing resolved no fold cells from "
|
|
684
|
+
"the invest solve(s)."
|
|
685
|
+
)
|
|
686
|
+
return size_timed(
|
|
687
|
+
dt_slack, residual, all_edges, tw_rep,
|
|
688
|
+
lam=lam, overshoot=overshoot, tol=tol,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
__all__ = [
|
|
693
|
+
"invest_weight_W",
|
|
694
|
+
"scalar_adder",
|
|
695
|
+
"size_timed",
|
|
696
|
+
"sized_increments",
|
|
697
|
+
"timed_increments",
|
|
698
|
+
"w_from_grids",
|
|
699
|
+
]
|