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,166 @@
|
|
|
1
|
+
"""Idempotent write of the per-scenario adequacy-calibration alternative.
|
|
2
|
+
|
|
3
|
+
Each calibrator iteration needs the current per-node
|
|
4
|
+
``energy_margin_adder`` (and its enabling ``energy_margin_method =
|
|
5
|
+
inflow_adder``) applied to the model *without editing the fixture's own
|
|
6
|
+
alternatives*. We do that by writing a dedicated calibration alternative
|
|
7
|
+
named ``<scenario>_adeq_calib`` and appending it to the scenario's
|
|
8
|
+
alternative stack at the TOP rank, so its values WIN over any baseline the
|
|
9
|
+
scenario already sets while leaving that baseline untouched.
|
|
10
|
+
|
|
11
|
+
The write is idempotent by construction: :func:`spinedb_api.import_data`
|
|
12
|
+
defaults to ``on_conflict='merge'`` (an UPDATE in place), so re-writing a
|
|
13
|
+
changed adder updates the single existing row rather than creating a
|
|
14
|
+
duplicate. Re-writing an *identical* state leaves nothing to commit, which
|
|
15
|
+
:meth:`DatabaseMapping.commit_session` signals by raising
|
|
16
|
+
:class:`NothingToCommit` — caught and treated as success.
|
|
17
|
+
|
|
18
|
+
The scenario link is wired SEPARATELY from the parameter import, by adding
|
|
19
|
+
only the single new ``(scenario, alt)`` row at the top rank. We must NOT
|
|
20
|
+
route it through ``import_data(scenario_alternatives=...)``: that path
|
|
21
|
+
re-derives the scenario's ENTIRE ordered stack and re-yields every existing
|
|
22
|
+
link with freshly recomputed *1-based* ranks. On a database whose stack is
|
|
23
|
+
stored with a different rank base (real FlexTool models use *0-based* ranks),
|
|
24
|
+
every existing link's rank then shifts by one and collides on the
|
|
25
|
+
``(scenario, rank)`` unique key — surfacing as spurious "already a
|
|
26
|
+
scenario_alternative" errors for the untouched baseline alternatives. The
|
|
27
|
+
single-row append below is base-agnostic and idempotent (mirrors
|
|
28
|
+
``representative_periods.scenario_stack.add_alternative_to_scenario``).
|
|
29
|
+
|
|
30
|
+
This module never solves and never touches the network beyond the target
|
|
31
|
+
SpineDB.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
|
|
38
|
+
from spinedb_api import DatabaseMapping, import_data
|
|
39
|
+
from spinedb_api.exception import NothingToCommit
|
|
40
|
+
from spinedb_api.parameter_value import Map
|
|
41
|
+
|
|
42
|
+
# Suffix appended to a scenario name to form its calibration alternative.
|
|
43
|
+
_CALIB_ALT_SUFFIX = "_adeq_calib"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _adder_map(cells: dict[tuple[str, str], float]) -> Map:
|
|
47
|
+
"""Build a 2-D ``(period → time → float)`` Map from per-cell adders.
|
|
48
|
+
|
|
49
|
+
*cells* is ``{(period, time): adder}`` (the ``timed`` sizer's output for
|
|
50
|
+
one node). The nested ``Map`` round-trips through ``import_data`` into the
|
|
51
|
+
``pdt_energy_margin_adder.csv`` spec the emitter reads, placing each cell's
|
|
52
|
+
value at exactly that invest ``(period, time)``. Periods and times are
|
|
53
|
+
emitted in sorted order for a deterministic, idempotent write.
|
|
54
|
+
"""
|
|
55
|
+
by_period: dict[str, dict[str, float]] = defaultdict(dict)
|
|
56
|
+
for (period, time), value in cells.items():
|
|
57
|
+
by_period[str(period)][str(time)] = float(value)
|
|
58
|
+
periods = sorted(by_period)
|
|
59
|
+
inner_maps = []
|
|
60
|
+
for period in periods:
|
|
61
|
+
times = sorted(by_period[period])
|
|
62
|
+
inner_maps.append(
|
|
63
|
+
Map(times, [by_period[period][t] for t in times], index_name="time")
|
|
64
|
+
)
|
|
65
|
+
return Map(periods, inner_maps, index_name="period")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def calib_alt_name(scenario: str) -> str:
|
|
69
|
+
"""Return the calibration alternative name for *scenario*.
|
|
70
|
+
|
|
71
|
+
A pure naming helper (``f"{scenario}{_CALIB_ALT_SUFFIX}"``) so callers
|
|
72
|
+
and tests agree on the alternative the calibrator writes into without
|
|
73
|
+
duplicating the string literal.
|
|
74
|
+
"""
|
|
75
|
+
return f"{scenario}{_CALIB_ALT_SUFFIX}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalise_url(url: str) -> str:
|
|
79
|
+
"""Accept either a bare filesystem path or a full SQLAlchemy URL.
|
|
80
|
+
|
|
81
|
+
A bare path (no ``"://"`` scheme) is promoted to a ``sqlite:///`` URL;
|
|
82
|
+
anything already carrying a scheme is passed through verbatim.
|
|
83
|
+
"""
|
|
84
|
+
return url if "://" in url else f"sqlite:///{url}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_calib_alt(
|
|
88
|
+
url: str,
|
|
89
|
+
scenario: str,
|
|
90
|
+
per_node_adder: "dict[str, float | dict[tuple[str, str], float]]",
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Write (or update) the calibration alternative for *scenario*.
|
|
93
|
+
|
|
94
|
+
For every ``node -> adder`` in *per_node_adder*, set both
|
|
95
|
+
``energy_margin_method = inflow_adder`` and ``energy_margin_adder`` on that
|
|
96
|
+
node under the ``<scenario>_adeq_calib`` alternative, and append that
|
|
97
|
+
alternative to *scenario*'s stack at the top rank (higher rank wins; the
|
|
98
|
+
existing stack is left intact).
|
|
99
|
+
|
|
100
|
+
The adder value is EITHER a scalar float (the ``uniform`` sizer — a
|
|
101
|
+
constant per-timestep margin) OR a ``{(period, time): float}`` map (the
|
|
102
|
+
``timed`` sizer — per-cell margin), written as a 2-D
|
|
103
|
+
``period → time → float`` :class:`spinedb_api.parameter_value.Map` that
|
|
104
|
+
the emitter ingests as ``pdt_energy_margin_adder.csv``. Both share the
|
|
105
|
+
idempotent-overwrite path.
|
|
106
|
+
|
|
107
|
+
Idempotent: re-writing a changed adder UPDATEs the single row in place
|
|
108
|
+
(``import_data`` merges on conflict); re-writing an identical state
|
|
109
|
+
commits nothing (:class:`NothingToCommit` is swallowed). An empty
|
|
110
|
+
*per_node_adder* still materialises the alternative and its scenario
|
|
111
|
+
link — so a baseline (k=0) iteration is solved *through* the calibration
|
|
112
|
+
alternative from the start, keeping the alternative stack constant
|
|
113
|
+
across every iteration.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
url:
|
|
118
|
+
Target SpineDB — a bare path (promoted to ``sqlite:///``) or a full
|
|
119
|
+
SQLAlchemy URL.
|
|
120
|
+
scenario:
|
|
121
|
+
The model scenario whose stack the calibration alternative joins.
|
|
122
|
+
per_node_adder:
|
|
123
|
+
``{node_name: adder_MWh}``. May be empty (baseline iteration).
|
|
124
|
+
"""
|
|
125
|
+
alt = calib_alt_name(scenario)
|
|
126
|
+
|
|
127
|
+
pvs: list[tuple] = []
|
|
128
|
+
for node, adder in per_node_adder.items():
|
|
129
|
+
pvs.append(("node", node, "energy_margin_method", "inflow_adder", alt))
|
|
130
|
+
value = _adder_map(adder) if isinstance(adder, dict) else float(adder)
|
|
131
|
+
pvs.append(("node", node, "energy_margin_adder", value, alt))
|
|
132
|
+
|
|
133
|
+
with DatabaseMapping(_normalise_url(url)) as db:
|
|
134
|
+
# 1. Materialise the calibration alternative and its per-node adders.
|
|
135
|
+
# Deliberately NO ``scenario_alternatives`` here — see the module
|
|
136
|
+
# docstring: routing the link through import_data re-ranks the whole
|
|
137
|
+
# stack (1-based) and collides on 0-based real-model stacks.
|
|
138
|
+
_count, errors = import_data(
|
|
139
|
+
db,
|
|
140
|
+
alternatives=[alt],
|
|
141
|
+
parameter_values=pvs,
|
|
142
|
+
)
|
|
143
|
+
assert not errors, errors
|
|
144
|
+
try:
|
|
145
|
+
db.commit_session("adeq_calib iteration")
|
|
146
|
+
except NothingToCommit:
|
|
147
|
+
# Re-writing an identical state changes nothing to persist.
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# 2. Append the calibration alternative to the scenario's stack at the
|
|
151
|
+
# TOP rank (max existing rank + 1, so its values WIN over the
|
|
152
|
+
# baseline). Idempotent + base-agnostic: touch ONLY the single new
|
|
153
|
+
# (scenario, alt) row, leaving every existing link's rank untouched.
|
|
154
|
+
existing = db.get_scenario_alternative_items(scenario_name=scenario)
|
|
155
|
+
if not any(sa["alternative_name"] == alt for sa in existing):
|
|
156
|
+
next_rank = max((sa["rank"] for sa in existing), default=0) + 1
|
|
157
|
+
db.add_scenario_alternative(
|
|
158
|
+
scenario_name=scenario, alternative_name=alt, rank=next_rank
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
db.commit_session("adeq_calib scenario link")
|
|
162
|
+
except NothingToCommit:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["calib_alt_name", "write_calib_alt"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Regenerate the non-parquet output formats from the final surviving parquet.
|
|
2
|
+
|
|
3
|
+
The calibration loop solves each iteration with ``--write-methods parquet``
|
|
4
|
+
only (fast; the loop reads its signals straight from the parquet), and clears
|
|
5
|
+
the tree between iterations, so when :func:`~flextool.calibrate._loop.run_calibration`
|
|
6
|
+
returns the LAST iteration's full results survive at
|
|
7
|
+
``<out_root>/output_parquet/<scenario>/``.
|
|
8
|
+
|
|
9
|
+
This module turns that surviving parquet tree into the other regular output
|
|
10
|
+
formats (csv / excel / spinedb / plot) **without re-solving** — it drives the
|
|
11
|
+
engine's own disk-replay path (:func:`flextool.process_outputs.write_outputs.write_outputs`
|
|
12
|
+
with ``read_parquet_dir=True``), the same code the ``flextool-write-outputs``
|
|
13
|
+
CLI and the Toolbox re-plot flow use. So the calibrated model's results are
|
|
14
|
+
available "just like regular results", but produced by re-reading parquet
|
|
15
|
+
rather than paying for another solve.
|
|
16
|
+
|
|
17
|
+
Known limitations of the replay path (inherent to reading back from parquet,
|
|
18
|
+
not to this module):
|
|
19
|
+
|
|
20
|
+
* ``spinedb`` replay cannot recover the two investment/operations
|
|
21
|
+
discount-factor parameters (the writer needs the live ``par`` frame) nor the
|
|
22
|
+
ordering of a two-way connection's (source, sink);
|
|
23
|
+
* ``csv`` replay writes the per-table frames only — it does not regenerate the
|
|
24
|
+
native run's ``summary.csv``.
|
|
25
|
+
|
|
26
|
+
parquet is intentionally excluded from the accepted methods: it is already on
|
|
27
|
+
disk (re-writing it would be a no-op the engine guards against anyway).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
# The formats this post-loop step can regenerate from parquet. ``parquet`` is
|
|
35
|
+
# excluded on purpose (already present); the rest are the regular output
|
|
36
|
+
# formats a normal run would emit.
|
|
37
|
+
FINAL_WRITE_METHOD_CHOICES = ("csv", "excel", "spinedb", "plot")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_final_outputs(
|
|
41
|
+
out_root: Path,
|
|
42
|
+
scenario: str,
|
|
43
|
+
write_methods: "list[str] | tuple[str, ...]",
|
|
44
|
+
*,
|
|
45
|
+
results_db_url: str | None = None,
|
|
46
|
+
) -> list[str]:
|
|
47
|
+
"""Replay the final parquet tree into *write_methods*; return what was written.
|
|
48
|
+
|
|
49
|
+
Reads ``<out_root>/output_parquet/<scenario>/`` (the surviving output of the
|
|
50
|
+
last calibration solve) and emits each requested format alongside it —
|
|
51
|
+
``output_csv/<scenario>/``, ``output_excel/output_<scenario>.xlsx``,
|
|
52
|
+
``output_plots/<scenario>/`` and/or a results SpineDB — with **no** solve.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
out_root:
|
|
57
|
+
The calibration ``--output-location`` root: the parent that holds
|
|
58
|
+
``output_parquet/`` (and gains the sibling ``output_csv/`` … trees).
|
|
59
|
+
scenario:
|
|
60
|
+
Scenario name; also the ``output_parquet`` sub-folder to read back.
|
|
61
|
+
write_methods:
|
|
62
|
+
Which formats to regenerate (a subset of
|
|
63
|
+
:data:`FINAL_WRITE_METHOD_CHOICES`). Empty ⇒ nothing to do.
|
|
64
|
+
results_db_url:
|
|
65
|
+
Target SpineDB URL for the ``spinedb`` method; defaults inside the
|
|
66
|
+
engine to ``<out_root>/results.sqlite`` when omitted.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
The list of methods actually written (empty when *write_methods* is empty).
|
|
71
|
+
|
|
72
|
+
Raises
|
|
73
|
+
------
|
|
74
|
+
FileNotFoundError
|
|
75
|
+
If the expected ``output_parquet/<scenario>/`` directory is absent —
|
|
76
|
+
i.e. there is no final solve to replay.
|
|
77
|
+
"""
|
|
78
|
+
methods = [m for m in write_methods if m]
|
|
79
|
+
if not methods:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
out_root = Path(out_root)
|
|
83
|
+
parquet_dir = out_root / "output_parquet" / scenario
|
|
84
|
+
if not parquet_dir.is_dir():
|
|
85
|
+
raise FileNotFoundError(
|
|
86
|
+
f"no parquet output to replay for scenario '{scenario}': "
|
|
87
|
+
f"expected {parquet_dir}/ (did the final solve write outputs?)"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Deferred import: write_outputs pulls in the plotting/Excel stack, which we
|
|
91
|
+
# do not want to load unless the operator actually asked for final outputs.
|
|
92
|
+
from flextool.process_outputs.write_outputs import write_outputs
|
|
93
|
+
|
|
94
|
+
write_outputs(
|
|
95
|
+
scenario_name=scenario,
|
|
96
|
+
output_location=str(out_root),
|
|
97
|
+
# subdir is the folder name under output_parquet/ — the scenario — so
|
|
98
|
+
# the replay reads exactly the tree the calibration loop left behind.
|
|
99
|
+
subdir=scenario,
|
|
100
|
+
read_parquet_dir=True,
|
|
101
|
+
write_methods=methods,
|
|
102
|
+
# Belt-and-suspenders: keep output_location non-empty even if the engine
|
|
103
|
+
# ever tries to fall back to settings resolution on the replay path.
|
|
104
|
+
fallback_output_location=str(out_root),
|
|
105
|
+
results_db_url=results_db_url,
|
|
106
|
+
)
|
|
107
|
+
return methods
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["FINAL_WRITE_METHOD_CHOICES", "write_final_outputs"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Over-build guard for the adequacy calibrator (C1c) — a PURE decision.
|
|
2
|
+
|
|
3
|
+
The sizer (C1b) keeps bumping ``energy_margin_adder`` on every node that
|
|
4
|
+
still sheds unserved energy. On a node whose shortfall is genuinely
|
|
5
|
+
resource-capped — no more firm capacity, imports, or storage can be built to
|
|
6
|
+
serve it — bumping the margin further just injects demand the solve cannot
|
|
7
|
+
serve, and (worse) concentrating demand on an unresponsive node drives the
|
|
8
|
+
solve toward infeasibility. This module decides, each correction, which
|
|
9
|
+
shedding nodes to KEEP bumping and which to FREEZE and FLAG as
|
|
10
|
+
resource-capped so the operator surfaces them for firm capacity / imports /
|
|
11
|
+
storage instead of more demand margin.
|
|
12
|
+
|
|
13
|
+
The observable that IS present
|
|
14
|
+
------------------------------
|
|
15
|
+
Earlier revisions gated the freeze on margin-induced CURTAILMENT rising AT
|
|
16
|
+
the shedding node (``C_k > C_0``). That is the WRONG observable for the
|
|
17
|
+
calibrator's real targets: demand nodes never curtail (VRE spill is upstream),
|
|
18
|
+
so ``C_k > C_0`` is permanently FALSE at a demand node and the guard could
|
|
19
|
+
NEVER flag it — while it kept concentrating demand there until the solve went
|
|
20
|
+
infeasible. The signature that IS present at a resource-capped demand node is
|
|
21
|
+
a residual that stays FLAT despite its adder rising: the margin is buying no
|
|
22
|
+
adequacy. So the freeze keys off the residual RESPONSE to the bump, not
|
|
23
|
+
curtailment.
|
|
24
|
+
|
|
25
|
+
The rule, per node ``n``, comparing the just-solved iteration ``k`` against
|
|
26
|
+
the prior iteration ``k-1``:
|
|
27
|
+
|
|
28
|
+
* ``ΔSlack(n) = S_{k-1}(n) − S_k(n)`` — unserved energy REDUCED (MWh) by the
|
|
29
|
+
bump ``n`` received last round.
|
|
30
|
+
|
|
31
|
+
Freeze ``n`` (and add it to the flagged set) when the PRECONDITION and the
|
|
32
|
+
STALL condition both hold:
|
|
33
|
+
|
|
34
|
+
* PRECONDITION — ``S_{k-1}(n) > shed_tol``: the node was ALREADY shedding in
|
|
35
|
+
the prior iteration, so it WAS bumped last round. The freeze rule asks "did
|
|
36
|
+
MY prior bump to this node fail to buy adequacy?", which is only meaningful
|
|
37
|
+
for a node that was actually shedding (and therefore bumped) last round. A
|
|
38
|
+
node with ``S_{k-1}(n) ≤ shed_tol`` was NOT shedding last round (so the sizer
|
|
39
|
+
proposed no bump for it) and only STARTS shedding this round — because margin
|
|
40
|
+
added to OTHER nodes shifted dispatch onto it. Such a genuinely short,
|
|
41
|
+
never-yet-bumped node must get its FIRST bump, never be frozen; without this
|
|
42
|
+
precondition its ``ΔSlack < 0`` (gap grew from zero) would trip the stall
|
|
43
|
+
condition (``ΔSlack < stall_fraction · 0 = 0``) and freeze it forever,
|
|
44
|
+
exactly backwards. ``shed_tol`` is the SAME per-node shedding tolerance the
|
|
45
|
+
sizer uses (:func:`._sizing.sized_increments`), threaded in so the two agree
|
|
46
|
+
on "was this node shedding?".
|
|
47
|
+
|
|
48
|
+
* STALL — ``ΔSlack(n) < stall_fraction · S_{k-1}(n)``: the residual barely
|
|
49
|
+
moved (or grew) in response to last round's bump, i.e. the margin bought
|
|
50
|
+
essentially no adequacy. ``stall_fraction`` is the CLI ``--stall-fraction``
|
|
51
|
+
knob (default :data:`_STALLED_GAP_FRACTION` = 0.05): a HIGHER value freezes a
|
|
52
|
+
stalled node SOONER (demands a larger relative residual drop to keep bumping).
|
|
53
|
+
|
|
54
|
+
The precondition plus the stall condition are required — deliberately
|
|
55
|
+
conservative, biased to trip one iteration LATE rather than early (every node
|
|
56
|
+
gets at least one bump before it can be flagged, and a little idle capacity
|
|
57
|
+
beats leaving real unserved energy on a node margin CAN still help). A
|
|
58
|
+
legitimately resource-capped node still gets frozen: it sheds every round (so
|
|
59
|
+
``S_{k-1} > shed_tol`` once it has been bumped) and its residual stalls, so the
|
|
60
|
+
condition trips after it has had its bump(s), and it never runs away to
|
|
61
|
+
infeasibility. The guard needs a prior iteration to diff, so it can only act
|
|
62
|
+
from the SECOND correction onward; the loop calls it only when a prior record
|
|
63
|
+
exists. Frozen nodes stay frozen for the rest of the run — the loop persists
|
|
64
|
+
the flagged set and never bumps a flagged node again.
|
|
65
|
+
|
|
66
|
+
Curtailment plays NO part in the freeze decision (the loop still reads it for
|
|
67
|
+
reporting). Everything here is pure arithmetic over plain dicts — no I/O — so
|
|
68
|
+
the rule is unit-testable in isolation, which is the point of C1c.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
# Default fraction of the prior gap below which ΔSlack counts as "stopped
|
|
74
|
+
# moving" (the STALL condition). Exposed on the CLI as ``--stall-fraction``;
|
|
75
|
+
# this constant is the default the loop threads in.
|
|
76
|
+
_STALLED_GAP_FRACTION = 0.05
|
|
77
|
+
|
|
78
|
+
# Default per-node shedding tolerance (MWh) for the freeze PRECONDITION —
|
|
79
|
+
# mirrors the sizer's ``_sizing._SHED_TOL_MWH`` so guard and sizer agree on
|
|
80
|
+
# "was this node shedding last round?". The loop threads the sizer's actual
|
|
81
|
+
# constant in; this default keeps the pure function standalone-testable.
|
|
82
|
+
_DEFAULT_SHED_TOL_MWH = 1e-6
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def guard_freeze(
|
|
86
|
+
candidate: dict[str, float],
|
|
87
|
+
*,
|
|
88
|
+
residual: dict[str, float],
|
|
89
|
+
prev_residual: dict[str, float],
|
|
90
|
+
stall_fraction: float = _STALLED_GAP_FRACTION,
|
|
91
|
+
shed_tol: float = _DEFAULT_SHED_TOL_MWH,
|
|
92
|
+
) -> tuple[dict[str, float], set[str]]:
|
|
93
|
+
"""Freeze resource-capped nodes out of *candidate* increments.
|
|
94
|
+
|
|
95
|
+
Applies the freeze rule (module docstring) to each node *candidate*
|
|
96
|
+
proposes to bump, using the current iteration ``k`` residual (*residual*)
|
|
97
|
+
and the prior iteration ``k-1`` residual (*prev_residual*). A node is
|
|
98
|
+
frozen (and flagged) when it was shedding last round
|
|
99
|
+
(``prev_residual[node] > shed_tol``) AND its residual failed to respond to
|
|
100
|
+
that bump (``ΔSlack < stall_fraction · prev_residual[node]``).
|
|
101
|
+
Curtailment is NOT consulted — a demand node never curtails, so keying the
|
|
102
|
+
freeze on curtailment could never flag it (the bug this replaces).
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
candidate
|
|
107
|
+
``{node: increment}`` the sizer proposes for this correction (already
|
|
108
|
+
stripped of persistently-flagged nodes by the caller).
|
|
109
|
+
residual, prev_residual
|
|
110
|
+
This iteration's and the prior iteration's per-node residual unserved
|
|
111
|
+
energy (MWh). A node absent from either contributes ``0.0``.
|
|
112
|
+
stall_fraction
|
|
113
|
+
Fraction of the prior gap below which ΔSlack counts as stalled
|
|
114
|
+
(``config.stall_fraction`` / ``--stall-fraction``). HIGHER freezes a
|
|
115
|
+
stalled node sooner.
|
|
116
|
+
shed_tol
|
|
117
|
+
Per-node shedding tolerance (MWh). A node is eligible for freezing
|
|
118
|
+
only if it was shedding LAST round (``prev_residual[node] > shed_tol``);
|
|
119
|
+
a node at/below it was not bumped last round and must get its first
|
|
120
|
+
bump. Threaded from the sizer so the two agree (default matches
|
|
121
|
+
:data:`._sizing._SHED_TOL_MWH`).
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
tuple[dict[str, float], set[str]]
|
|
126
|
+
``(kept, newly_flagged)`` — *candidate* with the frozen nodes removed,
|
|
127
|
+
and the set of nodes flagged this round.
|
|
128
|
+
"""
|
|
129
|
+
kept: dict[str, float] = {}
|
|
130
|
+
newly_flagged: set[str] = set()
|
|
131
|
+
for node, inc in candidate.items():
|
|
132
|
+
s_k = residual.get(node, 0.0)
|
|
133
|
+
s_prev = prev_residual.get(node, 0.0)
|
|
134
|
+
delta_slack = s_prev - s_k
|
|
135
|
+
|
|
136
|
+
# PRECONDITION: only a node that was shedding last round was bumped
|
|
137
|
+
# last round, so the freeze question is only meaningful for it. A
|
|
138
|
+
# newly-shedding node (s_prev <= shed_tol) must get its first bump.
|
|
139
|
+
cond_was_shedding = s_prev > shed_tol
|
|
140
|
+
# STALL: the residual barely moved despite the bump → margin buys no
|
|
141
|
+
# adequacy at this (resource-capped) node.
|
|
142
|
+
cond_stalled = delta_slack < stall_fraction * s_prev
|
|
143
|
+
|
|
144
|
+
if cond_was_shedding and cond_stalled:
|
|
145
|
+
newly_flagged.add(node)
|
|
146
|
+
else:
|
|
147
|
+
kept[node] = inc
|
|
148
|
+
return kept, newly_flagged
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["guard_freeze"]
|