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,722 @@
|
|
|
1
|
+
"""Solve-iteration driver for net-load representative-period selection (Phase 4).
|
|
2
|
+
|
|
3
|
+
This composes the single-shot net-load selector
|
|
4
|
+
(:func:`flextool.representative_periods.preprocess.preprocess_representative_periods`
|
|
5
|
+
with ``netload_clustering=True``) into an *iterate-until-stable* loop that
|
|
6
|
+
feeds each iteration's solved investment capacities back into the next
|
|
7
|
+
selection. The net-load signal is ``demand − Σ VRE·avail``; sizing the VRE
|
|
8
|
+
fleet by a solve's realised investments (instead of the iteration-0
|
|
9
|
+
demand-match default) sharpens which periods are net-load-critical, so the
|
|
10
|
+
representative set converges on the ones the *invested* system actually
|
|
11
|
+
stresses.
|
|
12
|
+
|
|
13
|
+
Loop shape (option C — cheap by default, keep-best opt-in)
|
|
14
|
+
----------------------------------------------------------
|
|
15
|
+
Iteration ``k`` in ``range(iterations + 1)``; ``k = 0`` is the BOOTSTRAP:
|
|
16
|
+
|
|
17
|
+
* **k = 0 (bootstrap).** Select on the demand-match default caps
|
|
18
|
+
(``solved_caps=None``), run the INVEST-ONLY solve, read the invested caps.
|
|
19
|
+
NO dispatch is run on the bootstrap — default-cap investments are known-bad,
|
|
20
|
+
so their full-year cost is not worth measuring.
|
|
21
|
+
* **k = 1..n.** Select with ``solved_caps = caps_{k-1}`` (the prior
|
|
22
|
+
iteration's invested fleet). If the newly-selected representative set is
|
|
23
|
+
UNCHANGED from the previous iteration → converged, break before solving.
|
|
24
|
+
Otherwise run the INVEST-ONLY solve and read the caps. When ``keep_best``
|
|
25
|
+
is set, ALSO run the full-year DISPATCH for this mature iteration, read its
|
|
26
|
+
total system cost, and track the lowest-cost representative set.
|
|
27
|
+
* **After the loop.** The chosen representative set is the keep-best winner
|
|
28
|
+
(if any dispatch ran and one was cheaper) else the last-selected set. When
|
|
29
|
+
keep-best picked an earlier iteration, its set is RE-MATERIALISED by
|
|
30
|
+
re-running the selector with that iteration's *input* caps — the net-load
|
|
31
|
+
selection is a deterministic function of the fed-back caps (greedy convex
|
|
32
|
+
hull over a fixed matrix, no randomness), so the same input caps reproduce
|
|
33
|
+
the exact same representative set into the pinned alternative. Finally ONE
|
|
34
|
+
DISPATCH is run on the chosen set for output.
|
|
35
|
+
|
|
36
|
+
Invariants owned here
|
|
37
|
+
----------------------
|
|
38
|
+
* **Stable alternative name.** The full ``alternative_name`` is pinned ONCE
|
|
39
|
+
in the config and passed as the explicit override to the selector EVERY
|
|
40
|
+
iteration, so the selector never derives a per-iteration name (whose
|
|
41
|
+
force-suffix could change as the forced count varies) and orphans timesets.
|
|
42
|
+
A stable name lets the selector's purge+rewrite cleanly REPLACE the single
|
|
43
|
+
timeset each iteration — running N iterations leaves exactly ONE net-load
|
|
44
|
+
timeset alternative, never an accumulation.
|
|
45
|
+
* **No shared warm-start basis.** The LP columns change whenever the
|
|
46
|
+
representative set changes, so a basis cached from a prior iteration would be
|
|
47
|
+
structurally stale. Each solve gets its OWN fresh (cold) basis-cache dir by
|
|
48
|
+
default (``warm_start=False``); ``warm_start=True`` opts into ONE shared dir
|
|
49
|
+
for the whole run (faster, but only safe if the grid is stable — use with
|
|
50
|
+
care). This deliberately contrasts the adequacy calibrator, whose grid is
|
|
51
|
+
fixed across iterations so it shares one cache.
|
|
52
|
+
|
|
53
|
+
Solve subset mechanism
|
|
54
|
+
----------------------
|
|
55
|
+
``cmd_run_flextool`` has NO ``--solves`` flag; the authoritative, DB-driven
|
|
56
|
+
way to run a subset of a scenario's solves is the ``model.solves`` Array,
|
|
57
|
+
resolved under the scenario's alternative stack
|
|
58
|
+
(:meth:`flextool.engine_polars._solve_config.SolveConfig.load_from_db`). The
|
|
59
|
+
driver therefore writes a dedicated ``model.solves`` OVERRIDE alternative at
|
|
60
|
+
the TOP of the scenario's stack (mirroring
|
|
61
|
+
:mod:`flextool.calibrate._db_alt`): set to the invest solve(s) for an
|
|
62
|
+
invest-only pass, and to the dispatch solves (or the scenario's full original
|
|
63
|
+
solve list) for a dispatch pass. This is a genuine subset run, not a silent
|
|
64
|
+
full-chain fallback.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import argparse
|
|
70
|
+
import sys
|
|
71
|
+
from dataclasses import dataclass, field
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
|
|
74
|
+
import spinedb_api as api
|
|
75
|
+
from spinedb_api import Array, DatabaseMapping, import_data
|
|
76
|
+
from spinedb_api.exception import NothingToCommit
|
|
77
|
+
|
|
78
|
+
from flextool.calibrate._readers import (
|
|
79
|
+
read_total_system_cost,
|
|
80
|
+
read_unit_capacity_total,
|
|
81
|
+
)
|
|
82
|
+
from flextool.calibrate._solve import run_solve
|
|
83
|
+
from flextool.calibrate._solve_status import assess_solve
|
|
84
|
+
from flextool.engine_polars._db_reader import get_single_entities
|
|
85
|
+
from flextool.gui.solve_reader import read_scenario_solves
|
|
86
|
+
from flextool.representative_periods.preprocess import (
|
|
87
|
+
preprocess_representative_periods,
|
|
88
|
+
)
|
|
89
|
+
from flextool.representative_periods.scenario_stack import (
|
|
90
|
+
add_alternative_to_scenario,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Required-output gates for the fail-closed solve assessment, per pass.
|
|
94
|
+
_INVEST_REQUIRED = ("unit_capacity_ed_p", "node_slack_up_d_e")
|
|
95
|
+
_DISPATCH_REQUIRED = ("costs_discounted_p_", "node_slack_up_d_e")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NetloadIterError(RuntimeError):
|
|
99
|
+
"""Raised when a net-load iteration cannot proceed.
|
|
100
|
+
|
|
101
|
+
Covers both a fail-closed solve (an untrusted iteration solve) and the
|
|
102
|
+
mutual-exclusion guard against a timed per-cell ``energy_margin_adder``.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class NetloadIterConfig:
|
|
108
|
+
"""Configuration for one net-load iteration run.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
n_rp: Number of representative periods to select.
|
|
112
|
+
period_length: Timesteps per representative period.
|
|
113
|
+
iterations: Number of ADJUSTMENT iterations AFTER the bootstrap; the
|
|
114
|
+
loop runs at most ``iterations + 1`` selections (``iterations = 0``
|
|
115
|
+
is bootstrap-only).
|
|
116
|
+
scenario: The scenario (model instance) to select + solve.
|
|
117
|
+
invest_solves: Solve name(s) to run for the INVEST-ONLY pass (written
|
|
118
|
+
into the ``model.solves`` override alternative).
|
|
119
|
+
dispatch_solves: Solve name(s) for the full-year DISPATCH pass;
|
|
120
|
+
``None`` means "the scenario's original ``model.solves`` chain"
|
|
121
|
+
(invest + dispatch, so the full chain re-invests on the chosen
|
|
122
|
+
representative set then dispatches over the full year).
|
|
123
|
+
work_dir: Working directory for the per-iteration solve subprocesses.
|
|
124
|
+
out_root: Output-location root; solve outputs land under
|
|
125
|
+
``out_root/output_parquet/<scenario>/``.
|
|
126
|
+
alternative_name: STABLE alternative name pinned by the driver and
|
|
127
|
+
passed to the selector every iteration (see the module docstring —
|
|
128
|
+
the #1 correctness guard against orphaned timesets).
|
|
129
|
+
vre_penetration: Energy-share target threaded into the demand-match
|
|
130
|
+
default caps for the bootstrap selection.
|
|
131
|
+
keep_best: When set, dispatch every MATURE iteration (k >= 1) and keep
|
|
132
|
+
the lowest full-year-cost representative set.
|
|
133
|
+
warm_start: When ``False`` (default) every solve gets a fresh cold
|
|
134
|
+
basis cache (safe — the grid changes across iterations); ``True``
|
|
135
|
+
shares one cache for the whole run (faster, only safe on a stable
|
|
136
|
+
grid).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
n_rp: int
|
|
140
|
+
period_length: int
|
|
141
|
+
iterations: int
|
|
142
|
+
scenario: str
|
|
143
|
+
invest_solves: list[str]
|
|
144
|
+
dispatch_solves: list[str] | None
|
|
145
|
+
work_dir: Path
|
|
146
|
+
out_root: Path
|
|
147
|
+
alternative_name: str
|
|
148
|
+
vre_penetration: float = 1.0
|
|
149
|
+
keep_best: bool = False
|
|
150
|
+
warm_start: bool = False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class NetloadIterRecord:
|
|
155
|
+
"""One iteration's observed state (the iteration trajectory element).
|
|
156
|
+
|
|
157
|
+
Attributes:
|
|
158
|
+
iteration: The iteration index ``k`` (0 = bootstrap).
|
|
159
|
+
input_caps: The ``solved_caps`` fed INTO this iteration's selection
|
|
160
|
+
(``None`` for the bootstrap). Kept so the keep-best winner can be
|
|
161
|
+
deterministically re-materialised by re-running the selector with
|
|
162
|
+
the same input caps.
|
|
163
|
+
rep_starts: The representative-period start keys selected this
|
|
164
|
+
iteration — the rp-set identity used for convergence.
|
|
165
|
+
solved_caps: The invested capacities READ from this iteration's
|
|
166
|
+
invest-only solve.
|
|
167
|
+
dispatch_cost: The full-year total system cost when this iteration was
|
|
168
|
+
dispatched (keep-best, mature iterations only), else ``None``.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
iteration: int
|
|
172
|
+
input_caps: dict[str, float] | None
|
|
173
|
+
rep_starts: tuple[str, ...]
|
|
174
|
+
solved_caps: dict[str, float]
|
|
175
|
+
dispatch_cost: float | None = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class NetloadIterResult:
|
|
180
|
+
"""Outcome of a net-load iteration run.
|
|
181
|
+
|
|
182
|
+
Attributes:
|
|
183
|
+
iterations_run: Number of invest-only solves actually performed.
|
|
184
|
+
converged: Whether the representative set stabilised (an iteration
|
|
185
|
+
re-selected the previous set) within the budget.
|
|
186
|
+
stop_reason: ``"converged"`` (rp set stabilised), ``"bootstrap_only"``
|
|
187
|
+
(``iterations == 0``), or ``"budget_exhausted"`` (ran the full
|
|
188
|
+
adjustment budget without stabilising).
|
|
189
|
+
final_rep_starts: The representative set written to the pinned
|
|
190
|
+
alternative and dispatched for output.
|
|
191
|
+
final_caps: The invested capacities associated with the final set.
|
|
192
|
+
best_cost: The lowest full-year dispatch cost seen (keep-best), else
|
|
193
|
+
``None``.
|
|
194
|
+
final_cost: The full-year total system cost of the final output
|
|
195
|
+
dispatch.
|
|
196
|
+
alternative_name: The pinned alternative the final set lives in.
|
|
197
|
+
trajectory: Per-iteration :class:`NetloadIterRecord` list.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
iterations_run: int
|
|
201
|
+
converged: bool
|
|
202
|
+
stop_reason: str
|
|
203
|
+
final_rep_starts: tuple[str, ...]
|
|
204
|
+
final_caps: dict[str, float]
|
|
205
|
+
best_cost: float | None
|
|
206
|
+
final_cost: float
|
|
207
|
+
alternative_name: str
|
|
208
|
+
trajectory: list[NetloadIterRecord] = field(default_factory=list)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalise_url(url: str) -> str:
|
|
212
|
+
"""Accept either a bare filesystem path or a full SQLAlchemy URL."""
|
|
213
|
+
return url if "://" in url else f"sqlite:///{url}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _guard_no_timed_energy_margin(url: str, scenario: str) -> None:
|
|
217
|
+
"""Fail closed when the scenario has an ACTIVE timed per-cell adder.
|
|
218
|
+
|
|
219
|
+
The ``energy_margin`` calibrator's ``pdt_energy_margin_adder`` places
|
|
220
|
+
demand margin at specific ``(period, time)`` representative CELLS.
|
|
221
|
+
Re-selecting the representative periods between iterations moves those
|
|
222
|
+
cells, stranding the adder, so net-load iteration is mutually exclusive
|
|
223
|
+
with an active timed adder. A node whose ``energy_margin_method`` resolves
|
|
224
|
+
to ``inflow_adder`` AND whose ``energy_margin_adder`` is a 2-D
|
|
225
|
+
(``period -> time -> value``) :class:`spinedb_api.Map` triggers a clear
|
|
226
|
+
error. A scalar (uniform) or 1-D (period-only) adder is grid-independent
|
|
227
|
+
and allowed; an adder whose method is not ``inflow_adder`` is inert and
|
|
228
|
+
ignored. Read under the scenario filter so the *effective* (top-rank)
|
|
229
|
+
method and adder value are seen.
|
|
230
|
+
"""
|
|
231
|
+
scen_config = api.filters.scenario_filter.scenario_filter_config(scenario)
|
|
232
|
+
with DatabaseMapping(_normalise_url(url)) as db:
|
|
233
|
+
api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
|
|
234
|
+
db.fetch_all("parameter_value")
|
|
235
|
+
|
|
236
|
+
methods: dict[str, object] = {}
|
|
237
|
+
for pv in db.find_parameter_values(
|
|
238
|
+
entity_class_name="node",
|
|
239
|
+
parameter_definition_name="energy_margin_method",
|
|
240
|
+
):
|
|
241
|
+
methods[pv["entity_name"]] = api.from_database(
|
|
242
|
+
pv["value"], pv["type"]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for pv in db.find_parameter_values(
|
|
246
|
+
entity_class_name="node",
|
|
247
|
+
parameter_definition_name="energy_margin_adder",
|
|
248
|
+
):
|
|
249
|
+
node = pv["entity_name"]
|
|
250
|
+
if methods.get(node) != "inflow_adder":
|
|
251
|
+
continue
|
|
252
|
+
value = api.from_database(pv["value"], pv["type"])
|
|
253
|
+
if isinstance(value, api.Map) and any(
|
|
254
|
+
isinstance(inner, api.Map) for inner in value.values
|
|
255
|
+
):
|
|
256
|
+
raise NetloadIterError(
|
|
257
|
+
f"Scenario '{scenario}' has an ACTIVE timed per-cell "
|
|
258
|
+
f"energy_margin_adder on node '{node}' "
|
|
259
|
+
f"(period -> time Map with method 'inflow_adder'). Net-load "
|
|
260
|
+
f"iteration re-selects representative periods each round, "
|
|
261
|
+
f"which would strand the per-cell adder — the two features "
|
|
262
|
+
f"are mutually exclusive. Use a scalar (uniform) adder, or "
|
|
263
|
+
f"run the net-load iteration without the timed margin."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _full_chain_solves(url: str, scenario: str) -> list[str]:
|
|
268
|
+
"""Return the scenario's ordered ``model.solves`` names (the full chain)."""
|
|
269
|
+
return [si.name for si in read_scenario_solves(_normalise_url(url), scenario)]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _write_solves_override_alt(
|
|
273
|
+
url: str, scenario: str, solves: list[str], alt_name: str
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Set ``model.solves`` = *solves* in a top-of-stack override alternative.
|
|
276
|
+
|
|
277
|
+
Writes (idempotently, via ``import_data`` merge) the ``model.solves`` Array
|
|
278
|
+
onto every ``model`` entity under a dedicated override alternative, then
|
|
279
|
+
appends that alternative to *scenario*'s stack at the top rank so its value
|
|
280
|
+
WINS over the baseline solve list. Re-writing a changed subset UPDATEs the
|
|
281
|
+
single row in place. When the database has no ``model`` entity (the
|
|
282
|
+
auto-wired single-solve case) there is nothing to override, so the natural
|
|
283
|
+
single solve runs and the override is skipped.
|
|
284
|
+
"""
|
|
285
|
+
with DatabaseMapping(_normalise_url(url)) as db:
|
|
286
|
+
models = get_single_entities(db=db, entity_class_name="model")
|
|
287
|
+
if not models:
|
|
288
|
+
return
|
|
289
|
+
entities = [("model", m) for m in models]
|
|
290
|
+
pvs = [
|
|
291
|
+
("model", m, "solves", Array(list(solves)), alt_name)
|
|
292
|
+
for m in models
|
|
293
|
+
]
|
|
294
|
+
_count, errors = import_data(
|
|
295
|
+
db,
|
|
296
|
+
alternatives=[alt_name],
|
|
297
|
+
entities=entities,
|
|
298
|
+
parameter_values=pvs,
|
|
299
|
+
)
|
|
300
|
+
assert not errors, errors
|
|
301
|
+
try:
|
|
302
|
+
db.commit_session(f"netload solves override: {solves}")
|
|
303
|
+
except NothingToCommit:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
existing = db.get_scenario_alternative_items(scenario_name=scenario)
|
|
307
|
+
if not any(sa["alternative_name"] == alt_name for sa in existing):
|
|
308
|
+
next_rank = max((sa["rank"] for sa in existing), default=0) + 1
|
|
309
|
+
db.add_scenario_alternative(
|
|
310
|
+
scenario_name=scenario, alternative_name=alt_name, rank=next_rank
|
|
311
|
+
)
|
|
312
|
+
try:
|
|
313
|
+
db.commit_session("netload solves override link")
|
|
314
|
+
except NothingToCommit:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _read_rep_starts(url: str, timeset_name: str, alternative_name: str) -> tuple[str, ...]:
|
|
319
|
+
"""Read the selected representative-period start keys (the rp-set identity).
|
|
320
|
+
|
|
321
|
+
The ``timeset_duration`` Map the selector writes is keyed by representative
|
|
322
|
+
period START timestep; its ordered index IS the representative set. Read
|
|
323
|
+
it back from the pinned ``(timeset, alternative)`` so the driver can detect
|
|
324
|
+
when a re-selection reproduces the previous set (convergence).
|
|
325
|
+
"""
|
|
326
|
+
with DatabaseMapping(_normalise_url(url)) as db:
|
|
327
|
+
db.fetch_all("parameter_value")
|
|
328
|
+
for pv in db.find_parameter_values(
|
|
329
|
+
entity_class_name="timeset",
|
|
330
|
+
parameter_definition_name="timeset_duration",
|
|
331
|
+
):
|
|
332
|
+
if (
|
|
333
|
+
pv["entity_name"] == timeset_name
|
|
334
|
+
and pv["alternative_name"] == alternative_name
|
|
335
|
+
):
|
|
336
|
+
value = api.from_database(pv["value"], pv["type"])
|
|
337
|
+
return tuple(str(idx) for idx in value.indexes)
|
|
338
|
+
return ()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _select(
|
|
342
|
+
url: str, config: NetloadIterConfig, solved_caps: dict[str, float] | None
|
|
343
|
+
) -> tuple[str, ...]:
|
|
344
|
+
"""Run one net-load selection into the pinned alternative; return its set.
|
|
345
|
+
|
|
346
|
+
Always passes the PINNED ``alternative_name`` and restricts the
|
|
347
|
+
``period_timeset`` repoint to the invest solves (so only the invest pass
|
|
348
|
+
runs on the representative timeset; the dispatch keeps its full-year
|
|
349
|
+
timeset). Returns the selected representative-period start keys.
|
|
350
|
+
"""
|
|
351
|
+
timeset_name = preprocess_representative_periods(
|
|
352
|
+
url,
|
|
353
|
+
scenario_name=config.scenario,
|
|
354
|
+
n_rp=config.n_rp,
|
|
355
|
+
period_length=config.period_length,
|
|
356
|
+
netload_clustering=True,
|
|
357
|
+
vre_penetration=config.vre_penetration,
|
|
358
|
+
solved_caps=solved_caps,
|
|
359
|
+
alternative_name=config.alternative_name,
|
|
360
|
+
solves=list(config.invest_solves),
|
|
361
|
+
)
|
|
362
|
+
add_alternative_to_scenario(url, config.scenario, config.alternative_name)
|
|
363
|
+
return _read_rep_starts(url, timeset_name, config.alternative_name)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _solve_once(
|
|
367
|
+
url: str,
|
|
368
|
+
config: NetloadIterConfig,
|
|
369
|
+
*,
|
|
370
|
+
solves: list[str],
|
|
371
|
+
required: tuple[str, ...],
|
|
372
|
+
cache_tag: str,
|
|
373
|
+
) -> Path:
|
|
374
|
+
"""Write the solves override, run one fail-closed solve, return assess_dir.
|
|
375
|
+
|
|
376
|
+
*cache_tag* names the per-solve basis-cache dir; a fresh (cold) dir per
|
|
377
|
+
solve is used unless ``warm_start`` opts into one shared dir.
|
|
378
|
+
"""
|
|
379
|
+
_write_solves_override_alt(
|
|
380
|
+
url, config.scenario, solves, f"{config.alternative_name}_solves"
|
|
381
|
+
)
|
|
382
|
+
cache_dir = config.work_dir / (
|
|
383
|
+
"warm_shared" if config.warm_start else f"cache_{cache_tag}"
|
|
384
|
+
)
|
|
385
|
+
run = run_solve(
|
|
386
|
+
url,
|
|
387
|
+
config.scenario,
|
|
388
|
+
work_dir=config.work_dir,
|
|
389
|
+
out_root=config.out_root,
|
|
390
|
+
cache_dir=cache_dir,
|
|
391
|
+
)
|
|
392
|
+
outcome = assess_solve(
|
|
393
|
+
run.assess_dir,
|
|
394
|
+
exit_code=run.returncode,
|
|
395
|
+
required_outputs=list(required),
|
|
396
|
+
started_at=run.started_at,
|
|
397
|
+
)
|
|
398
|
+
if not outcome.succeeded:
|
|
399
|
+
raise NetloadIterError(
|
|
400
|
+
f"solve of {solves} for scenario '{config.scenario}' not "
|
|
401
|
+
f"trusted: {outcome.reason}"
|
|
402
|
+
)
|
|
403
|
+
return run.assess_dir
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def run_netload_iteration(
|
|
407
|
+
url: str, config: NetloadIterConfig
|
|
408
|
+
) -> NetloadIterResult:
|
|
409
|
+
"""Run the net-load representative-period iteration for *config.scenario*.
|
|
410
|
+
|
|
411
|
+
See the module docstring for the loop shape. Raises
|
|
412
|
+
:class:`NetloadIterError` on an active timed energy-margin adder (the
|
|
413
|
+
mutual-exclusion guard) or any untrusted iteration solve (fail-closed).
|
|
414
|
+
"""
|
|
415
|
+
# Fail closed BEFORE any solve if a timed per-cell adder is active.
|
|
416
|
+
_guard_no_timed_energy_margin(url, config.scenario)
|
|
417
|
+
|
|
418
|
+
dispatch_solves = (
|
|
419
|
+
config.dispatch_solves
|
|
420
|
+
if config.dispatch_solves is not None
|
|
421
|
+
else _full_chain_solves(url, config.scenario)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
trajectory: list[NetloadIterRecord] = []
|
|
425
|
+
prev_starts: tuple[str, ...] | None = None
|
|
426
|
+
caps: dict[str, float] = {}
|
|
427
|
+
best_cost: float | None = None
|
|
428
|
+
best_input_caps: dict[str, float] | None = None
|
|
429
|
+
best_starts: tuple[str, ...] | None = None
|
|
430
|
+
best_caps: dict[str, float] | None = None
|
|
431
|
+
converged = False
|
|
432
|
+
stop_reason = "bootstrap_only" if config.iterations == 0 else "budget_exhausted"
|
|
433
|
+
iterations_run = 0
|
|
434
|
+
last_starts: tuple[str, ...] = ()
|
|
435
|
+
|
|
436
|
+
for k in range(config.iterations + 1):
|
|
437
|
+
input_caps = None if k == 0 else dict(caps)
|
|
438
|
+
print(
|
|
439
|
+
f"====== netload[{config.scenario}] iteration "
|
|
440
|
+
f"{k}/{config.iterations} — "
|
|
441
|
+
f"{'bootstrap (demand-match caps)' if k == 0 else 'fed-back caps'} "
|
|
442
|
+
f"======",
|
|
443
|
+
flush=True,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
rep_starts = _select(url, config, input_caps)
|
|
447
|
+
last_starts = rep_starts
|
|
448
|
+
|
|
449
|
+
# Convergence: a re-selection reproducing the previous set means the
|
|
450
|
+
# fixed point is reached — stop before spending another solve.
|
|
451
|
+
if prev_starts is not None and rep_starts == prev_starts:
|
|
452
|
+
converged = True
|
|
453
|
+
stop_reason = "converged"
|
|
454
|
+
print(
|
|
455
|
+
f"------ iteration {k}: representative set unchanged "
|
|
456
|
+
f"({len(rep_starts)} periods) — converged ------",
|
|
457
|
+
flush=True,
|
|
458
|
+
)
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
# Invest-only pass: read the fleet this representative set invests in.
|
|
462
|
+
assess_dir = _solve_once(
|
|
463
|
+
url,
|
|
464
|
+
config,
|
|
465
|
+
solves=list(config.invest_solves),
|
|
466
|
+
required=_INVEST_REQUIRED,
|
|
467
|
+
cache_tag=f"invest_{k}",
|
|
468
|
+
)
|
|
469
|
+
caps = read_unit_capacity_total(assess_dir)
|
|
470
|
+
iterations_run += 1
|
|
471
|
+
|
|
472
|
+
dispatch_cost: float | None = None
|
|
473
|
+
# Keep-best dispatches only MATURE iterations (k >= 1); the bootstrap's
|
|
474
|
+
# default-cap investments are known-bad and never dispatched.
|
|
475
|
+
if config.keep_best and k >= 1:
|
|
476
|
+
d_assess = _solve_once(
|
|
477
|
+
url,
|
|
478
|
+
config,
|
|
479
|
+
solves=list(dispatch_solves),
|
|
480
|
+
required=_DISPATCH_REQUIRED,
|
|
481
|
+
cache_tag=f"dispatch_{k}",
|
|
482
|
+
)
|
|
483
|
+
dispatch_cost = read_total_system_cost(d_assess)
|
|
484
|
+
if best_cost is None or dispatch_cost < best_cost:
|
|
485
|
+
best_cost = dispatch_cost
|
|
486
|
+
best_input_caps = input_caps
|
|
487
|
+
best_starts = rep_starts
|
|
488
|
+
best_caps = dict(caps)
|
|
489
|
+
print(
|
|
490
|
+
f"------ iteration {k}: full-year cost {dispatch_cost:.4f} M€ "
|
|
491
|
+
f"(best {best_cost:.4f} M€) ------",
|
|
492
|
+
flush=True,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
trajectory.append(
|
|
496
|
+
NetloadIterRecord(
|
|
497
|
+
iteration=k,
|
|
498
|
+
input_caps=input_caps,
|
|
499
|
+
rep_starts=rep_starts,
|
|
500
|
+
solved_caps=dict(caps),
|
|
501
|
+
dispatch_cost=dispatch_cost,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
prev_starts = rep_starts
|
|
505
|
+
|
|
506
|
+
# Choose the final representative set: the keep-best winner if one was
|
|
507
|
+
# dispatched cheaper, else the last-selected set.
|
|
508
|
+
if config.keep_best and best_starts is not None:
|
|
509
|
+
final_starts = best_starts
|
|
510
|
+
final_caps = dict(best_caps) if best_caps is not None else {}
|
|
511
|
+
# Re-materialise the winning set deterministically by re-selecting with
|
|
512
|
+
# the same input caps that produced it (idempotent when it is already
|
|
513
|
+
# the current pinned set).
|
|
514
|
+
reproduced = _select(url, config, best_input_caps)
|
|
515
|
+
if reproduced != final_starts:
|
|
516
|
+
raise NetloadIterError(
|
|
517
|
+
"keep-best re-selection did not reproduce the winning "
|
|
518
|
+
f"representative set (expected {final_starts}, got "
|
|
519
|
+
f"{reproduced}); net-load selection is expected to be a "
|
|
520
|
+
"deterministic function of the fed-back caps."
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
final_starts = last_starts
|
|
524
|
+
final_caps = dict(caps)
|
|
525
|
+
|
|
526
|
+
# One final DISPATCH on the chosen set for output.
|
|
527
|
+
print(
|
|
528
|
+
f"====== netload[{config.scenario}] final dispatch on "
|
|
529
|
+
f"{len(final_starts)} representative periods ======",
|
|
530
|
+
flush=True,
|
|
531
|
+
)
|
|
532
|
+
final_assess = _solve_once(
|
|
533
|
+
url,
|
|
534
|
+
config,
|
|
535
|
+
solves=list(dispatch_solves),
|
|
536
|
+
required=_DISPATCH_REQUIRED,
|
|
537
|
+
cache_tag="final",
|
|
538
|
+
)
|
|
539
|
+
final_cost = read_total_system_cost(final_assess)
|
|
540
|
+
|
|
541
|
+
print(
|
|
542
|
+
f"====== netload[{config.scenario}] finished: {stop_reason} "
|
|
543
|
+
f"(converged={converged}), {iterations_run} invest solve(s), "
|
|
544
|
+
f"final cost {final_cost:.4f} M€ ======",
|
|
545
|
+
flush=True,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return NetloadIterResult(
|
|
549
|
+
iterations_run=iterations_run,
|
|
550
|
+
converged=converged,
|
|
551
|
+
stop_reason=stop_reason,
|
|
552
|
+
final_rep_starts=final_starts,
|
|
553
|
+
final_caps=final_caps,
|
|
554
|
+
best_cost=best_cost,
|
|
555
|
+
final_cost=final_cost,
|
|
556
|
+
alternative_name=config.alternative_name,
|
|
557
|
+
trajectory=trajectory,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _resolve_solves(
|
|
562
|
+
url: str,
|
|
563
|
+
scenario: str,
|
|
564
|
+
invest_solves: list[str] | None,
|
|
565
|
+
dispatch_solves: list[str] | None,
|
|
566
|
+
) -> tuple[list[str], list[str] | None]:
|
|
567
|
+
"""Resolve invest / dispatch solve lists, auto-detecting when not given.
|
|
568
|
+
|
|
569
|
+
When ``invest_solves`` is not supplied, auto-detect it as the scenario's
|
|
570
|
+
solves that carry a non-empty ``invest_periods`` Array (an investment
|
|
571
|
+
solve — the exact test :mod:`flextool.gui.solve_reader` uses). An explicit
|
|
572
|
+
list is used verbatim. ``dispatch_solves`` is passed through unchanged
|
|
573
|
+
(``None`` = the scenario's full original ``model.solves`` chain). Raises
|
|
574
|
+
when no invest solve can be resolved.
|
|
575
|
+
"""
|
|
576
|
+
resolved_invest = invest_solves
|
|
577
|
+
if not resolved_invest:
|
|
578
|
+
infos = read_scenario_solves(_normalise_url(url), scenario)
|
|
579
|
+
resolved_invest = [si.name for si in infos if si.has_invest_periods]
|
|
580
|
+
if not resolved_invest:
|
|
581
|
+
raise NetloadIterError(
|
|
582
|
+
f"No invest solve found for scenario '{scenario}': none of its "
|
|
583
|
+
f"solves {[si.name for si in infos]} carry a non-empty "
|
|
584
|
+
f"invest_periods, and --invest-solves was not given."
|
|
585
|
+
)
|
|
586
|
+
return resolved_invest, dispatch_solves
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
590
|
+
"""Build the argument parser for the net-load iteration CLI."""
|
|
591
|
+
parser = argparse.ArgumentParser(
|
|
592
|
+
prog="flextool.representative_periods.netload_iterate",
|
|
593
|
+
description=(
|
|
594
|
+
"Iterate net-load representative-period selection, feeding each "
|
|
595
|
+
"iteration's solved investment capacities back into the next "
|
|
596
|
+
"selection until the representative set stabilises."
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
parser.add_argument("db", help="SpineDB URL (or path) of the model.")
|
|
600
|
+
parser.add_argument("scenario", help="Scenario (model instance) to run.")
|
|
601
|
+
parser.add_argument("--n-rp", type=int, required=True, dest="n_rp",
|
|
602
|
+
help="Number of representative periods to select.")
|
|
603
|
+
parser.add_argument("--period-length", type=int, required=True,
|
|
604
|
+
dest="period_length",
|
|
605
|
+
help="Timesteps per representative period.")
|
|
606
|
+
parser.add_argument("--iterations", type=int, required=True,
|
|
607
|
+
help="Adjustment iterations after the bootstrap "
|
|
608
|
+
"(0 = bootstrap-only).")
|
|
609
|
+
parser.add_argument("--invest-solves", type=str, default=None,
|
|
610
|
+
dest="invest_solves",
|
|
611
|
+
help="Comma-separated invest solve name(s) for the "
|
|
612
|
+
"invest-only pass. Omitted → auto-detect solves "
|
|
613
|
+
"with a non-empty invest_periods.")
|
|
614
|
+
parser.add_argument("--dispatch-solves", type=str, default=None,
|
|
615
|
+
dest="dispatch_solves",
|
|
616
|
+
help="Comma-separated dispatch solve name(s) for the "
|
|
617
|
+
"full-year pass. Omitted → the scenario's full "
|
|
618
|
+
"original model.solves chain.")
|
|
619
|
+
parser.add_argument("--vre-penetration", type=float, default=1.0,
|
|
620
|
+
dest="vre_penetration",
|
|
621
|
+
help="Energy-share target for the bootstrap "
|
|
622
|
+
"demand-match caps (default 1.0).")
|
|
623
|
+
parser.add_argument("--keep-best", action="store_true", dest="keep_best",
|
|
624
|
+
help="Dispatch each mature iteration and keep the "
|
|
625
|
+
"lowest full-year-cost representative set.")
|
|
626
|
+
parser.add_argument("--warm-start", action="store_true", dest="warm_start",
|
|
627
|
+
help="Share one basis cache across all solves (faster "
|
|
628
|
+
"but only safe on a stable grid; OFF by default "
|
|
629
|
+
"because the grid changes each iteration).")
|
|
630
|
+
parser.add_argument("--work-dir", type=Path, default=Path("netload_work"),
|
|
631
|
+
dest="work_dir",
|
|
632
|
+
help="Working directory for the per-iteration solves.")
|
|
633
|
+
parser.add_argument("--output-location", type=Path,
|
|
634
|
+
default=Path("netload_out"), dest="out_root",
|
|
635
|
+
help="Output-location root for the solve outputs.")
|
|
636
|
+
parser.add_argument("--alternative-name", type=str, default=None,
|
|
637
|
+
dest="alternative_name",
|
|
638
|
+
help="Override the pinned output alternative name "
|
|
639
|
+
"(default: netload_<n_rp>rp_<PL>h).")
|
|
640
|
+
return parser
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _split(value: str | None) -> list[str] | None:
|
|
644
|
+
if value is None:
|
|
645
|
+
return None
|
|
646
|
+
return [s.strip() for s in value.split(",") if s.strip()]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def main(argv: list[str] | None = None) -> int:
|
|
650
|
+
"""Run the net-load iteration from the command line.
|
|
651
|
+
|
|
652
|
+
Returns 0 on success; on a :class:`NetloadIterError` (fail-closed solve or
|
|
653
|
+
the timed-adder guard) prints the reason to stderr and returns 1.
|
|
654
|
+
"""
|
|
655
|
+
parser = build_parser()
|
|
656
|
+
args = parser.parse_args(argv)
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
invest_solves, dispatch_solves = _resolve_solves(
|
|
660
|
+
args.db,
|
|
661
|
+
args.scenario,
|
|
662
|
+
_split(args.invest_solves),
|
|
663
|
+
_split(args.dispatch_solves),
|
|
664
|
+
)
|
|
665
|
+
except NetloadIterError as exc:
|
|
666
|
+
print(f"net-load iteration failed: {exc}", file=sys.stderr)
|
|
667
|
+
return 1
|
|
668
|
+
|
|
669
|
+
alternative_name = args.alternative_name or (
|
|
670
|
+
f"netload_{args.n_rp}rp_{args.period_length}h"
|
|
671
|
+
)
|
|
672
|
+
config = NetloadIterConfig(
|
|
673
|
+
n_rp=args.n_rp,
|
|
674
|
+
period_length=args.period_length,
|
|
675
|
+
iterations=args.iterations,
|
|
676
|
+
scenario=args.scenario,
|
|
677
|
+
invest_solves=invest_solves,
|
|
678
|
+
dispatch_solves=dispatch_solves,
|
|
679
|
+
work_dir=Path(args.work_dir),
|
|
680
|
+
out_root=Path(args.out_root),
|
|
681
|
+
alternative_name=alternative_name,
|
|
682
|
+
vre_penetration=args.vre_penetration,
|
|
683
|
+
keep_best=args.keep_best,
|
|
684
|
+
warm_start=args.warm_start,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
result = run_netload_iteration(args.db, config)
|
|
689
|
+
except NetloadIterError as exc:
|
|
690
|
+
print(f"net-load iteration failed: {exc}", file=sys.stderr)
|
|
691
|
+
return 1
|
|
692
|
+
|
|
693
|
+
print(
|
|
694
|
+
f"\nNet-load iteration finished: {result.stop_reason} "
|
|
695
|
+
f"(converged={result.converged}), "
|
|
696
|
+
f"{result.iterations_run} invest solve(s).\n"
|
|
697
|
+
f" Representative set ({len(result.final_rep_starts)}): "
|
|
698
|
+
f"{list(result.final_rep_starts)}\n"
|
|
699
|
+
f" Alternative: '{result.alternative_name}'\n"
|
|
700
|
+
f" Final full-year cost: {result.final_cost:.4f} M€"
|
|
701
|
+
+ (
|
|
702
|
+
f" (best iterated: {result.best_cost:.4f} M€)"
|
|
703
|
+
if result.best_cost is not None
|
|
704
|
+
else ""
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
return 0
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
__all__ = [
|
|
711
|
+
"NetloadIterConfig",
|
|
712
|
+
"NetloadIterError",
|
|
713
|
+
"NetloadIterRecord",
|
|
714
|
+
"NetloadIterResult",
|
|
715
|
+
"build_parser",
|
|
716
|
+
"main",
|
|
717
|
+
"run_netload_iteration",
|
|
718
|
+
]
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
if __name__ == "__main__":
|
|
722
|
+
sys.exit(main())
|