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,703 @@
|
|
|
1
|
+
"""Recursive solve structure builder — rolling/nested solve expansion.
|
|
2
|
+
|
|
3
|
+
Architecture notes
|
|
4
|
+
------------------
|
|
5
|
+
|
|
6
|
+
* This is the algorithmically most-complex orchestration module. It
|
|
7
|
+
walks the solve tree, expanding rolling-window solves into per-roll
|
|
8
|
+
sub-solves and threading parent scope through nested ``contains_solves``
|
|
9
|
+
chains. Every orchestration phase downstream reads the flat
|
|
10
|
+
``all_solves`` list and per-solve time lists this module produces;
|
|
11
|
+
any divergence manifests as wrong solve ordering, missing rolls, or
|
|
12
|
+
mis-scoped active times.
|
|
13
|
+
|
|
14
|
+
* :class:`RecursiveSolveBuilder` mutates several fields of
|
|
15
|
+
``state.solve``:
|
|
16
|
+
|
|
17
|
+
- ``roll_counter[solve] += 1`` per roll (R-O5). Reset at the start
|
|
18
|
+
of every top-level ``define_solve_recursive`` call so repeated
|
|
19
|
+
invocations on the same SolveConfig don't desync.
|
|
20
|
+
- ``real_solves`` accumulates every solve name encountered (including
|
|
21
|
+
renamed solves).
|
|
22
|
+
- ``first_of_complete_solve`` / ``last_of_solve`` track per-complete-solve
|
|
23
|
+
boundary names for state initial / terminal constraints downstream.
|
|
24
|
+
- ``duplicate_solve(...)`` is invoked for the single-matching-period
|
|
25
|
+
rename path which fans an input solve into ``solve + "_" + period``.
|
|
26
|
+
|
|
27
|
+
* Lazy import of :class:`StochasticSolver`: ``recursive_solve`` only
|
|
28
|
+
needs ``find_next_timestep`` (called once when a child is constrained
|
|
29
|
+
by a parent's roll start). ``_stochastic`` does not currently import
|
|
30
|
+
back, but the lazy import is defensive against future cross-module
|
|
31
|
+
growth.
|
|
32
|
+
|
|
33
|
+
* :func:`get_active_time` is imported from :mod:`_timeline` and called
|
|
34
|
+
at every node of the tree walk.
|
|
35
|
+
|
|
36
|
+
Behavioural quirks
|
|
37
|
+
------------------
|
|
38
|
+
|
|
39
|
+
* **Rolling expansion** produces names ``solve + "_roll_" + str(counter)``.
|
|
40
|
+
The counter is sourced from ``state.solve.roll_counter[solve]`` and
|
|
41
|
+
incremented per roll.
|
|
42
|
+
|
|
43
|
+
* **Single-matching-period rename**: when a child solve's period set
|
|
44
|
+
intersects its parent's by exactly one period, the child is renamed
|
|
45
|
+
to ``solve + "_" + period`` and ``duplicate_solve`` carbon-copies the
|
|
46
|
+
parent's lockstep dicts under the new name. ``duplicate_solve`` does
|
|
47
|
+
NOT carbon-copy the period attributes (``invest_periods``,
|
|
48
|
+
``realized_periods``, …); the empty-scope fallback at the parent_scope
|
|
49
|
+
computation site catches this.
|
|
50
|
+
|
|
51
|
+
* **Multi-period child** (``len(matching_periods) > 1``) is the
|
|
52
|
+
single-solve-with-multi-period parent case: keep the original solve
|
|
53
|
+
name and let the active-time filter constrain it later.
|
|
54
|
+
|
|
55
|
+
* **"More than one solve in a rolling solve, not managed"** raise:
|
|
56
|
+
the fix-storage handoff between sibling rolling children of a
|
|
57
|
+
rolling parent isn't implemented downstream.
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import logging
|
|
62
|
+
from collections import namedtuple
|
|
63
|
+
from typing import TYPE_CHECKING
|
|
64
|
+
|
|
65
|
+
from flextool.engine_polars._solve_state import (
|
|
66
|
+
FlexToolConfigError,
|
|
67
|
+
SolveResult,
|
|
68
|
+
)
|
|
69
|
+
from flextool.engine_polars._timeline import get_active_time
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from flextool.engine_polars._solve_state import RunnerState
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# ParentSolveInfo — walked through the tree
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
ParentSolveInfo = namedtuple("ParentSolveInfo", ["solve", "roll"])
|
|
81
|
+
"""Parent context threaded into recursive ``define_solve_recursive`` calls.
|
|
82
|
+
|
|
83
|
+
* ``solve``: parent solve name, or ``None`` for top-level solves.
|
|
84
|
+
* ``roll``: parent's per-roll name, or ``None`` if the parent is single-
|
|
85
|
+
solve mode (or the call is top-level).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# RecursiveSolveBuilder
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RecursiveSolveBuilder:
|
|
95
|
+
"""Walks the solve tree and produces the flat ``all_solves`` ordering.
|
|
96
|
+
|
|
97
|
+
The entry point :meth:`define_solve_recursive` is called once per
|
|
98
|
+
top-level solve in ``state.solve.model_solve``. It dispatches to
|
|
99
|
+
:meth:`_process_rolling_solve` or :meth:`_process_single_solve`
|
|
100
|
+
based on the current solve's ``solve_mode`` and threads parent
|
|
101
|
+
scope through any ``contains_solves`` chains.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, state: "RunnerState") -> None:
|
|
105
|
+
self.state = state
|
|
106
|
+
self.logger: logging.Logger = state.logger
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Rolling expansion
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def create_rolling_solves(
|
|
113
|
+
self,
|
|
114
|
+
solve: str,
|
|
115
|
+
full_active_time_list: dict,
|
|
116
|
+
jump: float,
|
|
117
|
+
horizon: float,
|
|
118
|
+
start: list | None = None,
|
|
119
|
+
duration: float = -1,
|
|
120
|
+
) -> tuple[list[str], dict, dict]:
|
|
121
|
+
"""Expand *solve* into an overlapping sequence of "rolls".
|
|
122
|
+
|
|
123
|
+
Each roll is a sub-solve with name ``solve + "_roll_" + str(roll_counter)``
|
|
124
|
+
whose active-time list is a slice of *full_active_time_list*
|
|
125
|
+
bounded by the roll's start and horizon, and whose realized-time
|
|
126
|
+
list is a slice from the start to the next jump boundary. The
|
|
127
|
+
``state.solve.roll_counter[solve]`` counter is incremented per
|
|
128
|
+
roll produced.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
solve: Parent solve name (without roll suffix).
|
|
132
|
+
full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``
|
|
133
|
+
covering the entire solve's domain.
|
|
134
|
+
jump: Per-roll commitment horizon (hours).
|
|
135
|
+
horizon: Per-roll optimisation horizon (hours).
|
|
136
|
+
start: Optional ``[period, timestep]`` to start from. When
|
|
137
|
+
``None``, the first entry of *full_active_time_list*
|
|
138
|
+
wins.
|
|
139
|
+
duration: Total duration (hours) the rolling sequence
|
|
140
|
+
should cover. ``-1`` means "until the active time
|
|
141
|
+
list is exhausted".
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
``(roll_solve_names, active_time_lists, realized_time_lists)``.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
FlexToolConfigError: Start point not found in active time.
|
|
148
|
+
"""
|
|
149
|
+
active_time_lists: dict = dict()
|
|
150
|
+
realized_time_lists: dict = dict()
|
|
151
|
+
solves: list[str] = []
|
|
152
|
+
starts: list[list] = []
|
|
153
|
+
jumps: list[list] = []
|
|
154
|
+
horizons: list[list] = []
|
|
155
|
+
duration_counter: float = 0
|
|
156
|
+
horizon_counter: float = 0
|
|
157
|
+
jump_counter: float = 0
|
|
158
|
+
started: bool = False
|
|
159
|
+
ended: bool = False
|
|
160
|
+
last_index: list = [None, None]
|
|
161
|
+
|
|
162
|
+
# Single-pass walk: record ``[period, index]`` pairs at each
|
|
163
|
+
# jump / horizon / duration boundary.
|
|
164
|
+
for period, active_time in list(full_active_time_list.items()):
|
|
165
|
+
for i, step in enumerate(active_time):
|
|
166
|
+
if not ended:
|
|
167
|
+
if started:
|
|
168
|
+
if duration_counter >= float(duration) and duration != -1:
|
|
169
|
+
jumps.append(last_index)
|
|
170
|
+
horizons.append(last_index)
|
|
171
|
+
ended = True
|
|
172
|
+
break
|
|
173
|
+
if jump_counter >= float(jump):
|
|
174
|
+
jumps.append(last_index)
|
|
175
|
+
starts.append([period, i])
|
|
176
|
+
jump_counter -= float(jump)
|
|
177
|
+
if horizon_counter >= float(horizon):
|
|
178
|
+
horizons.append(last_index)
|
|
179
|
+
horizon_counter -= float(jump)
|
|
180
|
+
horizon_counter += float(step.duration)
|
|
181
|
+
jump_counter += float(step.duration)
|
|
182
|
+
duration_counter += float(step.duration)
|
|
183
|
+
last_index = [period, i]
|
|
184
|
+
else:
|
|
185
|
+
if start is None or (start == [period, step.timestep]):
|
|
186
|
+
starts.append([period, i])
|
|
187
|
+
started = True
|
|
188
|
+
horizon_counter += float(step.duration)
|
|
189
|
+
jump_counter += float(step.duration)
|
|
190
|
+
duration_counter += float(step.duration)
|
|
191
|
+
last_index = [period, i]
|
|
192
|
+
if not started:
|
|
193
|
+
message = "Start point not found"
|
|
194
|
+
self.logger.error(message)
|
|
195
|
+
raise FlexToolConfigError(message)
|
|
196
|
+
|
|
197
|
+
# If a roll opened but didn't end (no jump/horizon boundary
|
|
198
|
+
# crossed before the active time exhausted), close it at the
|
|
199
|
+
# last index.
|
|
200
|
+
diff = len(starts) - len(horizons)
|
|
201
|
+
for _ in range(0, diff):
|
|
202
|
+
horizons.append(last_index)
|
|
203
|
+
diff = len(starts) - len(jumps)
|
|
204
|
+
for _ in range(0, diff):
|
|
205
|
+
jumps.append(last_index)
|
|
206
|
+
|
|
207
|
+
# Pre-compute ordered period list for index-based range extraction.
|
|
208
|
+
period_order = list(full_active_time_list.keys())
|
|
209
|
+
period_pos = {p: i for i, p in enumerate(period_order)}
|
|
210
|
+
|
|
211
|
+
# Slice the full active-time list per-roll using the recorded
|
|
212
|
+
# boundary indexes.
|
|
213
|
+
for index, roll_start in enumerate(starts):
|
|
214
|
+
solve_name = solve + "_roll_" + str(self.state.solve.roll_counter[solve])
|
|
215
|
+
self.state.solve.roll_counter[solve] += 1
|
|
216
|
+
solves.append(solve_name)
|
|
217
|
+
active = self._extract_time_range(
|
|
218
|
+
full_active_time_list,
|
|
219
|
+
period_order,
|
|
220
|
+
period_pos,
|
|
221
|
+
roll_start,
|
|
222
|
+
horizons[index],
|
|
223
|
+
)
|
|
224
|
+
realized = self._extract_time_range(
|
|
225
|
+
full_active_time_list,
|
|
226
|
+
period_order,
|
|
227
|
+
period_pos,
|
|
228
|
+
roll_start,
|
|
229
|
+
jumps[index],
|
|
230
|
+
)
|
|
231
|
+
active_time_lists[solve_name] = active
|
|
232
|
+
realized_time_lists[solve_name] = realized
|
|
233
|
+
return solves, active_time_lists, realized_time_lists
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _extract_time_range(
|
|
237
|
+
full_active_time_list: dict,
|
|
238
|
+
period_order: list[str],
|
|
239
|
+
period_pos: dict[str, int],
|
|
240
|
+
range_start: list,
|
|
241
|
+
range_end: list,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""Slice *full_active_time_list* between *range_start* and *range_end*.
|
|
244
|
+
|
|
245
|
+
Both endpoints are inclusive ``[period, index_within_period]``
|
|
246
|
+
pairs. The four boundary cases are handled explicitly:
|
|
247
|
+
|
|
248
|
+
1. **Single-period range** (``range_start[0] == range_end[0]``):
|
|
249
|
+
one slice ``[start[1] : end[1] + 1]``.
|
|
250
|
+
2. **Start period** of a multi-period range: from ``start[1]``
|
|
251
|
+
to end-of-period.
|
|
252
|
+
3. **End period** of a multi-period range: from beginning to
|
|
253
|
+
``end[1] + 1``.
|
|
254
|
+
4. **Middle period(s)**: full period included.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``.
|
|
258
|
+
period_order: ordered list of period names from
|
|
259
|
+
*full_active_time_list*.
|
|
260
|
+
period_pos: ``period_name -> position_in_period_order``.
|
|
261
|
+
range_start: ``[period, index]`` start (inclusive).
|
|
262
|
+
range_end: ``[period, index]`` end (inclusive).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
New dict with the sliced sub-time-list.
|
|
266
|
+
"""
|
|
267
|
+
result: dict = {}
|
|
268
|
+
start_pos = period_pos[range_start[0]]
|
|
269
|
+
end_pos = period_pos[range_end[0]]
|
|
270
|
+
for pos in range(start_pos, end_pos + 1):
|
|
271
|
+
period = period_order[pos]
|
|
272
|
+
if pos == start_pos and pos == end_pos:
|
|
273
|
+
result[period] = full_active_time_list[period][
|
|
274
|
+
range_start[1] : range_end[1] + 1
|
|
275
|
+
]
|
|
276
|
+
elif pos == start_pos:
|
|
277
|
+
result[period] = full_active_time_list[period][range_start[1] :]
|
|
278
|
+
elif pos == end_pos:
|
|
279
|
+
result[period] = full_active_time_list[period][0 : range_end[1] + 1]
|
|
280
|
+
else:
|
|
281
|
+
result[period] = full_active_time_list[period]
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _filter_time_list_by_periods(
|
|
286
|
+
full_time_list: dict,
|
|
287
|
+
period_dict: dict,
|
|
288
|
+
solve_name: str,
|
|
289
|
+
) -> dict:
|
|
290
|
+
"""Filter *full_time_list* to periods listed in *period_dict[solve_name]*.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
full_time_list: ``{period: [ActiveTimeEntry, ...]}``.
|
|
294
|
+
period_dict: ``{solve: [(p_from, p_in), ...]}``.
|
|
295
|
+
solve_name: Lookup key in *period_dict*.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Sub-dict of *full_time_list* containing only periods present
|
|
299
|
+
in ``period_dict[solve_name]``.
|
|
300
|
+
"""
|
|
301
|
+
filtered: dict = dict()
|
|
302
|
+
for period_tuple in period_dict.get(solve_name, []):
|
|
303
|
+
period = period_tuple[1] # period_included
|
|
304
|
+
if period in full_time_list:
|
|
305
|
+
filtered[period] = full_time_list[period]
|
|
306
|
+
return filtered
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _get_periods_from_parent_time_list(parent_time_list: dict) -> set[str]:
|
|
310
|
+
"""Return the set of periods in *parent_time_list*."""
|
|
311
|
+
return set(parent_time_list.keys())
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _filter_time_list_by_parent_scope(
|
|
315
|
+
child_time_list: dict,
|
|
316
|
+
parent_periods: set[str],
|
|
317
|
+
) -> dict:
|
|
318
|
+
"""Restrict *child_time_list* to *parent_periods*."""
|
|
319
|
+
filtered: dict = dict()
|
|
320
|
+
for period, timesteps in child_time_list.items():
|
|
321
|
+
if period in parent_periods:
|
|
322
|
+
filtered[period] = timesteps
|
|
323
|
+
return filtered
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
# Rolling-solve dispatcher
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def _process_rolling_solve(
|
|
330
|
+
self,
|
|
331
|
+
solve: str,
|
|
332
|
+
complete_solve_name: str,
|
|
333
|
+
full_active_time_list: dict,
|
|
334
|
+
parent_info: ParentSolveInfo,
|
|
335
|
+
start: list | None,
|
|
336
|
+
duration: float,
|
|
337
|
+
) -> SolveResult:
|
|
338
|
+
"""Expand a rolling-window solve into per-roll sub-solves.
|
|
339
|
+
|
|
340
|
+
Recurses into ``contains_solves`` once per roll, threading the
|
|
341
|
+
roll's first timestep + the parent's jump as the child's
|
|
342
|
+
``start`` / ``duration``.
|
|
343
|
+
"""
|
|
344
|
+
# Lazy import: keep the cross-module dependency one-way at
|
|
345
|
+
# import time; both modules are needed only when a parent's roll
|
|
346
|
+
# constrains a child.
|
|
347
|
+
from flextool.engine_polars._stochastic import StochasticSolver
|
|
348
|
+
|
|
349
|
+
solves: list[str] = []
|
|
350
|
+
complete_solves: dict = dict()
|
|
351
|
+
active_time_lists: dict = dict()
|
|
352
|
+
fix_storage_time_lists: dict = dict()
|
|
353
|
+
realized_time_lists: dict = dict()
|
|
354
|
+
parent_roll_lists: dict = dict()
|
|
355
|
+
|
|
356
|
+
rolling_times = self.state.solve.rolling_times[solve] # [jump, horizon, duration]
|
|
357
|
+
if duration == -1:
|
|
358
|
+
duration = float(rolling_times[2])
|
|
359
|
+
|
|
360
|
+
# Find start timestep if constrained by parent.
|
|
361
|
+
period_start_timestep = start
|
|
362
|
+
if start is not None:
|
|
363
|
+
start_timestep = StochasticSolver(self.state).find_next_timestep(
|
|
364
|
+
full_active_time_list, start, parent_info.solve, solve
|
|
365
|
+
)
|
|
366
|
+
period_start_timestep = [start[0], start_timestep]
|
|
367
|
+
|
|
368
|
+
# Create rolling solves.
|
|
369
|
+
roll_solves, roll_active_time_lists, roll_realized_time_lists = (
|
|
370
|
+
self.create_rolling_solves(
|
|
371
|
+
solve,
|
|
372
|
+
full_active_time_list,
|
|
373
|
+
float(rolling_times[0]),
|
|
374
|
+
float(rolling_times[1]),
|
|
375
|
+
period_start_timestep,
|
|
376
|
+
duration,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Track metadata for each roll.
|
|
381
|
+
for roll_name in roll_solves:
|
|
382
|
+
complete_solves[roll_name] = complete_solve_name
|
|
383
|
+
parent_roll_lists[roll_name] = parent_info.roll
|
|
384
|
+
|
|
385
|
+
active_time_lists.update(roll_active_time_lists)
|
|
386
|
+
|
|
387
|
+
# For rolling solves, fix_storage and realized are the same
|
|
388
|
+
# (the "jump" portion).
|
|
389
|
+
fix_storage_time_lists.update(roll_realized_time_lists)
|
|
390
|
+
realized_time_lists.update(roll_realized_time_lists)
|
|
391
|
+
|
|
392
|
+
# Mark first solve of this complete solve (for state start
|
|
393
|
+
# constraints). When this rolling solve is itself nested under
|
|
394
|
+
# another (parent_info.roll != None), only mark it as a
|
|
395
|
+
# first-of-complete if the parent roll was already in the list.
|
|
396
|
+
if parent_info.roll is not None:
|
|
397
|
+
if parent_info.roll in self.state.solve.first_of_complete_solve:
|
|
398
|
+
self.state.solve.first_of_complete_solve.append(roll_solves[0])
|
|
399
|
+
else:
|
|
400
|
+
self.state.solve.first_of_complete_solve.append(roll_solves[0])
|
|
401
|
+
|
|
402
|
+
self.state.solve.last_of_solve.append(roll_solves[-1])
|
|
403
|
+
|
|
404
|
+
# Process contained solves.
|
|
405
|
+
if solve in self.state.solve.contains_solves:
|
|
406
|
+
contain_solves = self.state.solve.contains_solves[solve]
|
|
407
|
+
if len(contain_solves) > 1:
|
|
408
|
+
message = "More than one solve in a rolling solve, not managed"
|
|
409
|
+
logging.error(message)
|
|
410
|
+
raise FlexToolConfigError(message)
|
|
411
|
+
|
|
412
|
+
contains_solve = contain_solves[0]
|
|
413
|
+
|
|
414
|
+
for index, roll_name in enumerate(roll_solves):
|
|
415
|
+
solves.append(roll_name)
|
|
416
|
+
|
|
417
|
+
# The first roll passes start=None (use first active-time
|
|
418
|
+
# entry); subsequent rolls explicitly pass the roll's
|
|
419
|
+
# first timestep as the child's start point.
|
|
420
|
+
if index != 0:
|
|
421
|
+
first_period = list(roll_active_time_lists[roll_name].keys())[0]
|
|
422
|
+
first_timestep = roll_active_time_lists[roll_name][first_period][0].timestep
|
|
423
|
+
child_start = [first_period, first_timestep]
|
|
424
|
+
else:
|
|
425
|
+
child_start = None
|
|
426
|
+
|
|
427
|
+
# Child duration equals parent's jump (the realized portion).
|
|
428
|
+
child_duration = float(rolling_times[0])
|
|
429
|
+
|
|
430
|
+
# Get parent's realized periods for child to use as scope.
|
|
431
|
+
parent_realized_periods = set(roll_realized_time_lists[roll_name].keys())
|
|
432
|
+
|
|
433
|
+
# Recursively process child solve.
|
|
434
|
+
child_parent_info = ParentSolveInfo(solve=solve, roll=roll_name)
|
|
435
|
+
child = self.define_solve_recursive(
|
|
436
|
+
contains_solve,
|
|
437
|
+
child_parent_info,
|
|
438
|
+
parent_realized_periods,
|
|
439
|
+
child_start,
|
|
440
|
+
child_duration,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
solves += child.solves
|
|
444
|
+
complete_solves.update(child.complete_solves)
|
|
445
|
+
parent_roll_lists.update(child.parent_roll_lists)
|
|
446
|
+
active_time_lists.update(child.active_time_lists)
|
|
447
|
+
fix_storage_time_lists.update(child.fix_storage_time_lists)
|
|
448
|
+
realized_time_lists.update(child.realized_time_lists)
|
|
449
|
+
else:
|
|
450
|
+
solves += roll_solves
|
|
451
|
+
|
|
452
|
+
return SolveResult(
|
|
453
|
+
solves=solves,
|
|
454
|
+
complete_solves=complete_solves,
|
|
455
|
+
active_time_lists=active_time_lists,
|
|
456
|
+
fix_storage_time_lists=fix_storage_time_lists,
|
|
457
|
+
realized_time_lists=realized_time_lists,
|
|
458
|
+
parent_roll_lists=parent_roll_lists,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
# Single-solve dispatcher
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def _process_single_solve(
|
|
466
|
+
self,
|
|
467
|
+
solve: str,
|
|
468
|
+
full_active_time_list: dict,
|
|
469
|
+
parent_info: ParentSolveInfo,
|
|
470
|
+
complete_solve_name: str | None = None,
|
|
471
|
+
) -> SolveResult:
|
|
472
|
+
"""Process a single-solve (non-rolling) solve.
|
|
473
|
+
|
|
474
|
+
When *solve* has children (``contains_solves[solve]``) recurse
|
|
475
|
+
into each child with parent scope = union of fix_storage,
|
|
476
|
+
realized, and realized_invest periods of *solve*. When the
|
|
477
|
+
scope is empty (because *solve* was renamed via the
|
|
478
|
+
single-matching-period path and ``duplicate_solve`` did not
|
|
479
|
+
carbon-copy the period attributes), fall back to *solve*'s own
|
|
480
|
+
active periods.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
solve: Active solve name (may be a renamed copy).
|
|
484
|
+
full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``.
|
|
485
|
+
parent_info: ParentSolveInfo from the caller.
|
|
486
|
+
complete_solve_name: Original (un-renamed) solve name. When
|
|
487
|
+
``None`` defaults to *solve*. See module docstring for
|
|
488
|
+
why renamed solves keep their original complete-solve
|
|
489
|
+
pointer.
|
|
490
|
+
"""
|
|
491
|
+
if complete_solve_name is None:
|
|
492
|
+
complete_solve_name = solve
|
|
493
|
+
|
|
494
|
+
solves: list[str] = [solve]
|
|
495
|
+
complete_solves: dict = dict()
|
|
496
|
+
active_time_lists: dict = dict()
|
|
497
|
+
invest_time_lists: dict = dict()
|
|
498
|
+
fix_storage_time_lists: dict = dict()
|
|
499
|
+
realized_time_lists: dict = dict()
|
|
500
|
+
parent_roll_lists: dict = dict()
|
|
501
|
+
|
|
502
|
+
complete_solves[solve] = complete_solve_name
|
|
503
|
+
parent_roll_lists[solve] = parent_info.roll
|
|
504
|
+
active_time_lists[solve] = full_active_time_list
|
|
505
|
+
|
|
506
|
+
# Period-shaped subsets of the full active time list.
|
|
507
|
+
invest_time_lists[solve] = self._filter_time_list_by_periods(
|
|
508
|
+
full_active_time_list, self.state.solve.invest_periods, solve
|
|
509
|
+
)
|
|
510
|
+
fix_storage_time_lists[solve] = self._filter_time_list_by_periods(
|
|
511
|
+
full_active_time_list, self.state.solve.fix_storage_periods, solve
|
|
512
|
+
)
|
|
513
|
+
realized_time_lists[solve] = self._filter_time_list_by_periods(
|
|
514
|
+
full_active_time_list, self.state.solve.realized_periods, solve
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
self.state.solve.first_of_complete_solve.append(solve)
|
|
518
|
+
self.state.solve.last_of_solve.append(solve)
|
|
519
|
+
|
|
520
|
+
# Process contained solves.
|
|
521
|
+
if solve in self.state.solve.contains_solves:
|
|
522
|
+
contain_solves = self.state.solve.contains_solves[solve]
|
|
523
|
+
|
|
524
|
+
# Parent scope: union of fix_storage, realized, and
|
|
525
|
+
# realized_invest periods. Invest solves only have
|
|
526
|
+
# invest/realized_invest periods (not fix_storage/realized),
|
|
527
|
+
# so realized_invest is needed to provide scope for their
|
|
528
|
+
# children.
|
|
529
|
+
realized_invest_time_list = self._filter_time_list_by_periods(
|
|
530
|
+
full_active_time_list, self.state.solve.realized_invest_periods, solve
|
|
531
|
+
)
|
|
532
|
+
parent_scope_periods = (
|
|
533
|
+
set(fix_storage_time_lists[solve].keys())
|
|
534
|
+
| set(realized_time_lists[solve].keys())
|
|
535
|
+
| set(realized_invest_time_list.keys())
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Fallback: when a solve was renamed (e.g.
|
|
539
|
+
# ``storage_fullYear_6h_p2020``), its period attributes
|
|
540
|
+
# aren't copied by ``duplicate_solve``, leaving scope empty.
|
|
541
|
+
# Use the solve's own active periods as scope in that case.
|
|
542
|
+
if not parent_scope_periods:
|
|
543
|
+
parent_scope_periods = set(full_active_time_list.keys())
|
|
544
|
+
|
|
545
|
+
for contain_solve in contain_solves:
|
|
546
|
+
child_parent_info = ParentSolveInfo(solve=solve, roll=solve)
|
|
547
|
+
child = self.define_solve_recursive(
|
|
548
|
+
contain_solve, child_parent_info, parent_scope_periods, None, -1
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
solves += child.solves
|
|
552
|
+
complete_solves.update(child.complete_solves)
|
|
553
|
+
parent_roll_lists.update(child.parent_roll_lists)
|
|
554
|
+
active_time_lists.update(child.active_time_lists)
|
|
555
|
+
fix_storage_time_lists.update(child.fix_storage_time_lists)
|
|
556
|
+
realized_time_lists.update(child.realized_time_lists)
|
|
557
|
+
|
|
558
|
+
return SolveResult(
|
|
559
|
+
solves=solves,
|
|
560
|
+
complete_solves=complete_solves,
|
|
561
|
+
active_time_lists=active_time_lists,
|
|
562
|
+
fix_storage_time_lists=fix_storage_time_lists,
|
|
563
|
+
realized_time_lists=realized_time_lists,
|
|
564
|
+
parent_roll_lists=parent_roll_lists,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
# Recursive entry
|
|
569
|
+
# ------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
def define_solve_recursive(
|
|
572
|
+
self,
|
|
573
|
+
solve: str,
|
|
574
|
+
parent_info: ParentSolveInfo,
|
|
575
|
+
parent_scope_periods: set[str] | None = None,
|
|
576
|
+
start: list | None = None,
|
|
577
|
+
duration: float = -1,
|
|
578
|
+
) -> SolveResult:
|
|
579
|
+
"""Walk the solve tree rooted at *solve*.
|
|
580
|
+
|
|
581
|
+
For each invocation:
|
|
582
|
+
|
|
583
|
+
1. Append *solve* to ``state.solve.real_solves`` (deduplicated).
|
|
584
|
+
2. If *solve* has a parent and exactly one period overlaps,
|
|
585
|
+
rename it to ``solve + "_" + period`` via
|
|
586
|
+
:meth:`SolveConfig.duplicate_solve` and re-shape
|
|
587
|
+
``timesets_used_by_solves`` to keep only entries for that
|
|
588
|
+
period.
|
|
589
|
+
3. Compute the full active-time list via :func:`get_active_time`.
|
|
590
|
+
4. If *parent_scope_periods* is non-empty, restrict the active-
|
|
591
|
+
time list to those periods.
|
|
592
|
+
5. Dispatch to :meth:`_process_rolling_solve` or
|
|
593
|
+
:meth:`_process_single_solve`.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
solve: Solve name to process.
|
|
597
|
+
parent_info: ParentSolveInfo (top-level call uses
|
|
598
|
+
``ParentSolveInfo(None, None)``).
|
|
599
|
+
parent_scope_periods: Periods the parent committed to.
|
|
600
|
+
``None`` for top-level solves.
|
|
601
|
+
start: ``[period, timestep]`` start (rolling-window child
|
|
602
|
+
only).
|
|
603
|
+
duration: Hours of duration to cover (rolling-window child
|
|
604
|
+
only).
|
|
605
|
+
"""
|
|
606
|
+
new_name = solve
|
|
607
|
+
if new_name not in self.state.solve.real_solves:
|
|
608
|
+
self.state.solve.real_solves.append(new_name)
|
|
609
|
+
|
|
610
|
+
# Single-matching-period rename path. When the child's period
|
|
611
|
+
# set intersects the parent's by exactly one period, rename
|
|
612
|
+
# ``solve`` → ``solve + "_" + period`` and carbon-copy the
|
|
613
|
+
# parent dicts. Multiple-period overlap keeps the original
|
|
614
|
+
# name (single-solve-with-multi-period parent case).
|
|
615
|
+
if parent_info.solve:
|
|
616
|
+
current_solve_periods = (
|
|
617
|
+
{t[0] for t in self.state.solve.invest_periods[solve]}
|
|
618
|
+
| {t[0] for t in self.state.solve.fix_storage_periods[solve]}
|
|
619
|
+
| {t[0] for t in self.state.solve.realized_periods[solve]}
|
|
620
|
+
)
|
|
621
|
+
parent_period = (
|
|
622
|
+
{t[0] for t in self.state.solve.invest_periods[parent_info.solve]}
|
|
623
|
+
| {t[0] for t in self.state.solve.fix_storage_periods[parent_info.solve]}
|
|
624
|
+
| {t[0] for t in self.state.solve.realized_periods[parent_info.solve]}
|
|
625
|
+
)
|
|
626
|
+
matching_periods = list(current_solve_periods & parent_period)
|
|
627
|
+
if len(matching_periods) == 1:
|
|
628
|
+
current_solve_period = matching_periods[0]
|
|
629
|
+
new_name = solve + "_" + str(current_solve_period)
|
|
630
|
+
self.state.solve.duplicate_solve(
|
|
631
|
+
solve, new_name, update_model_solves=False
|
|
632
|
+
)
|
|
633
|
+
self.state.solve.solve_period_years_represented[new_name] = (
|
|
634
|
+
self.state.solve.solve_period_years_represented[solve]
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
new_period_timeset_list = [
|
|
638
|
+
pt
|
|
639
|
+
for pt in self.state.solve.timesets_used_by_solves.get(solve, [])
|
|
640
|
+
if pt[0] == current_solve_period
|
|
641
|
+
]
|
|
642
|
+
if new_name not in self.state.solve.timesets_used_by_solves:
|
|
643
|
+
self.state.solve.timesets_used_by_solves[new_name] = (
|
|
644
|
+
new_period_timeset_list
|
|
645
|
+
)
|
|
646
|
+
else:
|
|
647
|
+
existing = self.state.solve.timesets_used_by_solves[new_name]
|
|
648
|
+
for item in new_period_timeset_list:
|
|
649
|
+
if item not in existing:
|
|
650
|
+
existing.append(item)
|
|
651
|
+
# When multiple periods match (single-solve parent with
|
|
652
|
+
# multiple periods), keep the original solve name and all
|
|
653
|
+
# its timesets — the active time will be filtered by
|
|
654
|
+
# parent scope later.
|
|
655
|
+
|
|
656
|
+
# Full active time list for this solve (every timestep it
|
|
657
|
+
# could potentially use).
|
|
658
|
+
full_active_time_list_own = get_active_time(
|
|
659
|
+
new_name,
|
|
660
|
+
self.state.solve.timesets_used_by_solves,
|
|
661
|
+
self.state.timeline.timeset_durations,
|
|
662
|
+
self.state.timeline.timelines,
|
|
663
|
+
self.state.timeline.timesets__timeline,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# Restrict to parent scope when applicable.
|
|
667
|
+
if not parent_scope_periods:
|
|
668
|
+
full_active_time_list = full_active_time_list_own
|
|
669
|
+
else:
|
|
670
|
+
full_active_time_list = self._filter_time_list_by_parent_scope(
|
|
671
|
+
full_active_time_list_own, parent_scope_periods
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Determine solve mode.
|
|
675
|
+
solve_mode = self.state.solve.solve_modes.get(new_name, "single_solve")
|
|
676
|
+
if solve_mode == "rolling_window":
|
|
677
|
+
complete_solve_name = solve
|
|
678
|
+
return self._process_rolling_solve(
|
|
679
|
+
new_name,
|
|
680
|
+
complete_solve_name,
|
|
681
|
+
full_active_time_list,
|
|
682
|
+
parent_info,
|
|
683
|
+
start,
|
|
684
|
+
duration,
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
# Single-solve: pass the *original* name as
|
|
688
|
+
# complete_solve_name so renamed solves (e.g.
|
|
689
|
+
# ``storage_fullYear_6h_p2020``) keep mapping back to the
|
|
690
|
+
# complete-solve name in ``state.solve.real_solves`` /
|
|
691
|
+
# ``solve_period_history``.
|
|
692
|
+
return self._process_single_solve(
|
|
693
|
+
new_name,
|
|
694
|
+
full_active_time_list,
|
|
695
|
+
parent_info,
|
|
696
|
+
complete_solve_name=solve,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
__all__ = [
|
|
701
|
+
"ParentSolveInfo",
|
|
702
|
+
"RecursiveSolveBuilder",
|
|
703
|
+
]
|