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,558 @@
|
|
|
1
|
+
"""Driver skeleton for the adequacy-margin calibrator (C1a).
|
|
2
|
+
|
|
3
|
+
This is the loop that COMPOSES P1's ``energy_margin_adder`` knob and P2's
|
|
4
|
+
solve-success detector into an iterate-until-adequate cycle, plus the pure
|
|
5
|
+
readers of :mod:`flextool.calibrate._readers`. It runs end to end today —
|
|
6
|
+
solve, verify, read the per-node residual unserved energy — but it does
|
|
7
|
+
**not** yet size the adder or guard against over-build. Those are C1b
|
|
8
|
+
(sizing) and C1c (the over-build guard); this slice leaves a clean,
|
|
9
|
+
signature-stable seam for them in :func:`compute_step`.
|
|
10
|
+
|
|
11
|
+
Loop shape (per iteration ``k`` in ``range(iterations + 1)``; ``k=0`` is the
|
|
12
|
+
BASELINE)::
|
|
13
|
+
|
|
14
|
+
1. write_calib_alt(url, scenario, adders) # k=0 writes empty/zero
|
|
15
|
+
2. clear (or, in debug, archive) the prior output_parquet
|
|
16
|
+
3. run = run_solve(...); outcome = assess_solve(run..., started_at=...)
|
|
17
|
+
-> not outcome.succeeded ⇒ raise CalibError (fail-closed)
|
|
18
|
+
4. residual = read_residual_unserved(run.assess_dir)
|
|
19
|
+
curtailment, penalty read too; record the iteration
|
|
20
|
+
5. total_unserved <= slack_threshold_mwh ⇒ converged, break
|
|
21
|
+
6. increments = compute_step(..., W=W) # C1b sizing (no guard)
|
|
22
|
+
adders[node] += increment # bumps shedding nodes
|
|
23
|
+
|
|
24
|
+
The solve is *fail-closed*: an unverified solve (missing/stale/empty
|
|
25
|
+
required outputs, or an unoverridable nonzero exit) raises rather than
|
|
26
|
+
letting the calibrator step on numbers it cannot trust.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import shutil
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from flextool.calibrate._db_alt import calib_alt_name, write_calib_alt
|
|
37
|
+
from flextool.calibrate._guard import guard_freeze
|
|
38
|
+
from flextool.calibrate._readers import (
|
|
39
|
+
read_curtailment_by_sink,
|
|
40
|
+
read_residual_unserved,
|
|
41
|
+
read_residual_unserved_dt,
|
|
42
|
+
read_slack_penalty,
|
|
43
|
+
)
|
|
44
|
+
from flextool.calibrate._sizing import (
|
|
45
|
+
_SHED_TOL_MWH,
|
|
46
|
+
invest_weight_W,
|
|
47
|
+
sized_increments,
|
|
48
|
+
timed_increments,
|
|
49
|
+
)
|
|
50
|
+
from flextool.calibrate._solve import run_solve
|
|
51
|
+
from flextool.calibrate._solve_status import assess_solve
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CalibError(RuntimeError):
|
|
55
|
+
"""Raised when an iteration's solve cannot be trusted (fail-closed).
|
|
56
|
+
|
|
57
|
+
Carries the solve-success detector's ``reason`` so the operator sees
|
|
58
|
+
exactly why the run was rejected (missing/stale outputs, unoverridable
|
|
59
|
+
nonzero exit, …) instead of a bare failure.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CalibConfig:
|
|
65
|
+
"""Configuration for a calibration run.
|
|
66
|
+
|
|
67
|
+
``iterations`` — number of ADJUSTMENT iterations after the
|
|
68
|
+
baseline; the loop runs ``iterations + 1``
|
|
69
|
+
solves (iteration 0 is the baseline).
|
|
70
|
+
``slack_threshold_mwh`` — total residual unserved energy at or below
|
|
71
|
+
which the run is considered converged.
|
|
72
|
+
``damping_first`` /
|
|
73
|
+
``damping_remaining`` — sizing damping factors (consumed by C1b).
|
|
74
|
+
``overshoot`` — planning-margin SAFETY multiplier on every sized
|
|
75
|
+
increment (default 1.0 = off; >1 provisions
|
|
76
|
+
beyond the measured slack for unmodeled
|
|
77
|
+
multi-year risk; MODEL-DEPENDENT).
|
|
78
|
+
``stall_fraction`` — over-build-guard STALL fraction (C1c): a shedding
|
|
79
|
+
node whose residual drops by less than this
|
|
80
|
+
fraction of its prior gap in response to its bump
|
|
81
|
+
is frozen as resource-capped. Higher freezes
|
|
82
|
+
sooner. Default 0.05.
|
|
83
|
+
``over_build_tightness``— RETAINED for CLI/config compatibility only; the
|
|
84
|
+
C1c guard no longer gates on curtailment
|
|
85
|
+
efficiency, so this value is not consulted (the
|
|
86
|
+
freeze is driven by ``stall_fraction``).
|
|
87
|
+
``warm_start_cache_dir``— stable basis-cache dir shared across
|
|
88
|
+
iterations.
|
|
89
|
+
``work_dir`` — subprocess working directory.
|
|
90
|
+
``out_root`` — output-location root; results land under
|
|
91
|
+
``out_root/output_parquet/<scenario>/``.
|
|
92
|
+
``debug`` — when True, archive each iteration's outputs to
|
|
93
|
+
``out_root/out_iter_<k>/`` instead of clearing
|
|
94
|
+
them.
|
|
95
|
+
``sizing`` — adder placement mode: ``"uniform"`` (default) —
|
|
96
|
+
a constant per-timestep margin sized ``λ·res/W``;
|
|
97
|
+
or ``"timed"`` — the same total energy placed
|
|
98
|
+
per-cell at the low-VRE stress hours (folded from
|
|
99
|
+
``node_slack_up_dt_e``).
|
|
100
|
+
``final_write_methods`` — output formats to regenerate from the final
|
|
101
|
+
surviving parquet AFTER the loop, without
|
|
102
|
+
re-solving (a subset of csv/excel/spinedb/plot;
|
|
103
|
+
parquet is always already present). Empty ⇒ leave
|
|
104
|
+
the parquet-only outputs as-is. Consumed by the
|
|
105
|
+
CLI, not the loop itself.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
iterations: int
|
|
109
|
+
slack_threshold_mwh: float
|
|
110
|
+
damping_first: float
|
|
111
|
+
damping_remaining: float
|
|
112
|
+
over_build_tightness: float
|
|
113
|
+
warm_start_cache_dir: Path
|
|
114
|
+
work_dir: Path
|
|
115
|
+
out_root: Path
|
|
116
|
+
debug: bool = False
|
|
117
|
+
sizing: str = "uniform"
|
|
118
|
+
overshoot: float = 1.0
|
|
119
|
+
stall_fraction: float = 0.05
|
|
120
|
+
final_write_methods: tuple[str, ...] = ("csv",)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class IterRecord:
|
|
125
|
+
"""One iteration's observed state (the calibration trajectory element).
|
|
126
|
+
|
|
127
|
+
``adders`` is the per-node adder snapshot that was WRITTEN and SOLVED
|
|
128
|
+
for this iteration (captured before any increment is applied), so the
|
|
129
|
+
trajectory pairs each observation with the input that produced it. For
|
|
130
|
+
``uniform`` sizing each value is a scalar float; for ``timed`` sizing it
|
|
131
|
+
is a ``{(period, time): float}`` per-cell map.
|
|
132
|
+
|
|
133
|
+
``solve_seconds`` is this iteration's wall-clock solve time (end −
|
|
134
|
+
``started_at``), so the report surfaces the per-iteration cost (and the
|
|
135
|
+
warm-start speedup across iterations) directly.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
iteration: int
|
|
139
|
+
adders: "dict[str, float | dict[tuple[str, str], float]]"
|
|
140
|
+
residual: dict[str, float]
|
|
141
|
+
curtailment: dict[str, float]
|
|
142
|
+
penalty_total: float
|
|
143
|
+
penalty_by_node: dict[str, float]
|
|
144
|
+
solve_seconds: float = 0.0
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def total_unserved(self) -> float:
|
|
148
|
+
"""Total residual unserved energy (MWh) across all nodes."""
|
|
149
|
+
return float(sum(self.residual.values()))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class CalibResult:
|
|
154
|
+
"""Outcome of a calibration run.
|
|
155
|
+
|
|
156
|
+
``converged`` — whether total unserved fell to/under the
|
|
157
|
+
threshold within the iteration budget.
|
|
158
|
+
``stop_reason`` — the finer three-way signal of WHY the loop
|
|
159
|
+
stopped: ``"converged"`` (total unserved met
|
|
160
|
+
the threshold), ``"stalled"`` (no further bump
|
|
161
|
+
was possible — every remaining shedding node is
|
|
162
|
+
resource-capped, so the demand-margin lever is
|
|
163
|
+
exhausted — while still above threshold), or
|
|
164
|
+
``"budget_exhausted"`` (ran the full
|
|
165
|
+
``iterations`` budget without converging or
|
|
166
|
+
stalling). ``converged`` stays ``True`` only
|
|
167
|
+
for the threshold case, so downstream flags keep
|
|
168
|
+
working; ``stop_reason`` distinguishes the two
|
|
169
|
+
non-converged exits.
|
|
170
|
+
``iterations_run`` — number of solves actually performed.
|
|
171
|
+
``final_adders`` — the per-node adders after the last step.
|
|
172
|
+
``trajectory`` — per-iteration :class:`IterRecord` list.
|
|
173
|
+
``guard_flagged_nodes`` — nodes the over-build guard flagged (C1c;
|
|
174
|
+
empty for now).
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
converged: bool
|
|
178
|
+
iterations_run: int
|
|
179
|
+
final_adders: dict[str, float]
|
|
180
|
+
trajectory: list[IterRecord]
|
|
181
|
+
guard_flagged_nodes: list[str] = field(default_factory=list)
|
|
182
|
+
stop_reason: str = "budget_exhausted"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def compute_step(
|
|
186
|
+
residual: dict[str, float],
|
|
187
|
+
penalty_by_node: dict[str, float],
|
|
188
|
+
prev_record: IterRecord | None,
|
|
189
|
+
config: CalibConfig,
|
|
190
|
+
*,
|
|
191
|
+
W: float,
|
|
192
|
+
flagged: set[str],
|
|
193
|
+
url: str | None = None,
|
|
194
|
+
scenario: str | None = None,
|
|
195
|
+
dt_slack: "dict[str, dict[tuple[str, str], float]] | None" = None,
|
|
196
|
+
) -> "tuple[dict[str, float | dict[tuple[str, str], float]], set[str]]":
|
|
197
|
+
"""Compute per-node adder INCREMENTS for the next iteration.
|
|
198
|
+
|
|
199
|
+
**C1b — sizing.** Each shedding node's residual unserved energy is
|
|
200
|
+
converted into an ``energy_margin_adder`` increment that (undamped) would
|
|
201
|
+
inject that residual annual MWh back as demand, in one of two modes:
|
|
202
|
+
|
|
203
|
+
* ``config.sizing == "uniform"`` — a CONSTANT per-timestep increment
|
|
204
|
+
``increment(node) = λ · residual(node) / W`` (see
|
|
205
|
+
:func:`flextool.calibrate._sizing.sized_increments`); ``W`` is the
|
|
206
|
+
invest-timeline annualisation weight, computed ONCE by the loop.
|
|
207
|
+
* ``config.sizing == "timed"`` — the SAME total energy folded from the
|
|
208
|
+
node's ``node_slack_up_dt_e`` stress profile onto the representative
|
|
209
|
+
cells, so ``increment(node)`` is a ``{(period, time): float}`` map placed
|
|
210
|
+
at the stressed hours (see
|
|
211
|
+
:func:`flextool.calibrate._sizing.timed_increments`). Requires *url*,
|
|
212
|
+
*scenario* and this iteration's *dt_slack* profile.
|
|
213
|
+
|
|
214
|
+
Every sized increment carries the ``config.overshoot`` planning-margin
|
|
215
|
+
SAFETY multiplier (default 1.0 = off; >1 provisions beyond the measured
|
|
216
|
+
slack) — applied identically in both sizing modes.
|
|
217
|
+
|
|
218
|
+
``λ`` is the damping factor: ``config.damping_first`` on the FIRST
|
|
219
|
+
correction (no prior bump yet — ``prev_record is None``) and
|
|
220
|
+
``config.damping_remaining`` thereafter. Non-shedding nodes get no
|
|
221
|
+
increment.
|
|
222
|
+
|
|
223
|
+
**C1c — over-build guard.** On top of sizing this step:
|
|
224
|
+
|
|
225
|
+
1. drops any node already in *flagged* — a node flagged resource-capped
|
|
226
|
+
stays frozen for the rest of the run and is never bumped again;
|
|
227
|
+
2. from the SECOND correction onward (``prev_record is not None``) runs
|
|
228
|
+
:func:`flextool.calibrate._guard.guard_freeze` to REMOVE and FLAG any
|
|
229
|
+
node whose residual FAILED to respond to its prior bump — the freeze
|
|
230
|
+
keys off *residual* (this iteration) and *prev_record.residual* (the
|
|
231
|
+
prior iteration): a node that was shedding last round
|
|
232
|
+
(``prev_record.residual > shed_tol``, the same tolerance the sizer used
|
|
233
|
+
above) but whose gap dropped by less than ``config.stall_fraction`` of
|
|
234
|
+
its prior value is resource-capped (margin buys it no adequacy) and is
|
|
235
|
+
frozen. Curtailment is NOT consulted (a demand node never curtails, so
|
|
236
|
+
keying the freeze on curtailment could never flag it). A node that only
|
|
237
|
+
STARTS shedding this round was never bumped, so it gets its first bump
|
|
238
|
+
rather than being frozen. On the first correction there is no prior to
|
|
239
|
+
diff, so nothing is flagged and every shedding node is bumped.
|
|
240
|
+
|
|
241
|
+
``penalty_by_node`` is this iteration's monetised slack, carried for
|
|
242
|
+
reporting/diagnostics.
|
|
243
|
+
|
|
244
|
+
Returns ``(increments, newly_flagged)``: ``{node: increment_MWh}`` to ADD
|
|
245
|
+
to the running adders (a missing node means "no change"), and the set of
|
|
246
|
+
nodes newly flagged this round for the loop to union into its persistent
|
|
247
|
+
flagged set.
|
|
248
|
+
"""
|
|
249
|
+
lam = config.damping_first if prev_record is None else config.damping_remaining
|
|
250
|
+
if config.sizing == "timed":
|
|
251
|
+
if url is None or scenario is None or dt_slack is None:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"timed sizing requires url, scenario and dt_slack to be "
|
|
254
|
+
"threaded into compute_step."
|
|
255
|
+
)
|
|
256
|
+
increments = timed_increments(
|
|
257
|
+
residual, dt_slack, url, scenario,
|
|
258
|
+
lam=lam, overshoot=config.overshoot,
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
increments = sized_increments(
|
|
262
|
+
residual, W=W, lam=lam, overshoot=config.overshoot,
|
|
263
|
+
)
|
|
264
|
+
# A persistently-flagged node is resource-capped: never bump it again.
|
|
265
|
+
increments = {n: v for n, v in increments.items() if n not in flagged}
|
|
266
|
+
|
|
267
|
+
# The guard can only diff against a prior iteration, so it acts from the
|
|
268
|
+
# SECOND correction onward; the first correction bumps all shedding nodes.
|
|
269
|
+
if prev_record is None:
|
|
270
|
+
return increments, set()
|
|
271
|
+
|
|
272
|
+
return guard_freeze(
|
|
273
|
+
increments,
|
|
274
|
+
residual=residual,
|
|
275
|
+
prev_residual=prev_record.residual,
|
|
276
|
+
stall_fraction=config.stall_fraction,
|
|
277
|
+
# Same shedding tolerance the sizer used above, so guard and sizer
|
|
278
|
+
# agree on which nodes were shedding (and thus bumped) last round.
|
|
279
|
+
shed_tol=_SHED_TOL_MWH,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _dt_slack_lookup(
|
|
284
|
+
assess_dir: Path,
|
|
285
|
+
) -> dict[str, dict[tuple[str, str], float]]:
|
|
286
|
+
"""Read ``node_slack_up_dt_e`` into a ``{node: {(period, time): slack}}``
|
|
287
|
+
lookup for the timed sizer (drops null / zero cells)."""
|
|
288
|
+
out: dict[str, dict[tuple[str, str], float]] = {}
|
|
289
|
+
for node, frame in read_residual_unserved_dt(assess_dir).items():
|
|
290
|
+
cells: dict[tuple[str, str], float] = {}
|
|
291
|
+
for period, time_, value in frame.itertuples(index=False):
|
|
292
|
+
v = float(value)
|
|
293
|
+
if v != 0.0:
|
|
294
|
+
cells[(str(period), str(time_))] = v
|
|
295
|
+
if cells:
|
|
296
|
+
out[str(node)] = cells
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _copy_adders(
|
|
301
|
+
adders: "dict[str, float | dict[tuple[str, str], float]]",
|
|
302
|
+
) -> "dict[str, float | dict[tuple[str, str], float]]":
|
|
303
|
+
"""Snapshot the adder state (per-cell maps copied, scalars passed through)."""
|
|
304
|
+
return {
|
|
305
|
+
node: (dict(val) if isinstance(val, dict) else val)
|
|
306
|
+
for node, val in adders.items()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _accumulate_adder(
|
|
311
|
+
adders: "dict[str, float | dict[tuple[str, str], float]]",
|
|
312
|
+
node: str,
|
|
313
|
+
inc: "float | dict[tuple[str, str], float]",
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Add *inc* into ``adders[node]`` in place.
|
|
316
|
+
|
|
317
|
+
Scalar increments accumulate arithmetically (uniform sizing); per-cell map
|
|
318
|
+
increments accumulate CELL-WISE (timed sizing), so a node's stressed cells
|
|
319
|
+
keep rising across corrections just as the scalar adder does.
|
|
320
|
+
"""
|
|
321
|
+
if isinstance(inc, dict):
|
|
322
|
+
cur = adders.get(node)
|
|
323
|
+
if not isinstance(cur, dict):
|
|
324
|
+
cur = {}
|
|
325
|
+
for cell, v in inc.items():
|
|
326
|
+
cur[cell] = cur.get(cell, 0.0) + v
|
|
327
|
+
adders[node] = cur
|
|
328
|
+
else:
|
|
329
|
+
base = adders.get(node, 0.0)
|
|
330
|
+
adders[node] = (base if isinstance(base, float) else 0.0) + inc
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _prepare_out_root(out_root: Path, iteration: int, debug: bool) -> None:
|
|
334
|
+
"""Clear (or, in debug, archive) the prior ``output_parquet`` tree.
|
|
335
|
+
|
|
336
|
+
A clean output directory each iteration is what makes P2's freshness
|
|
337
|
+
check meaningful: a genuinely failed solve skips output writing and so
|
|
338
|
+
would leave the previous iteration's files in place. In debug mode the
|
|
339
|
+
prior tree is preserved under ``out_iter_<k-1>/`` for inspection; a
|
|
340
|
+
stray tree present before the baseline (k=0) is simply removed.
|
|
341
|
+
"""
|
|
342
|
+
prior = Path(out_root) / "output_parquet"
|
|
343
|
+
if not prior.exists():
|
|
344
|
+
return
|
|
345
|
+
if debug and iteration > 0:
|
|
346
|
+
dest = Path(out_root) / f"out_iter_{iteration - 1}"
|
|
347
|
+
if dest.exists():
|
|
348
|
+
shutil.rmtree(dest)
|
|
349
|
+
shutil.move(str(prior), str(dest))
|
|
350
|
+
else:
|
|
351
|
+
shutil.rmtree(prior)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def run_calibration(
|
|
355
|
+
url: str, scenario: str, config: CalibConfig,
|
|
356
|
+
) -> CalibResult:
|
|
357
|
+
"""Run the adequacy-margin calibration loop for *scenario*.
|
|
358
|
+
|
|
359
|
+
Solves ``config.iterations + 1`` times (iteration 0 is the baseline),
|
|
360
|
+
verifying every solve with P2's detector and reading the per-node
|
|
361
|
+
residual unserved energy each time. With the C1b :func:`compute_step`
|
|
362
|
+
each shedding node's residual is sized into an ``energy_margin_adder``
|
|
363
|
+
increment (``λ · residual / W``) and accumulated, so the run actually
|
|
364
|
+
raises adders and drives slack down across iterations. C1c adds the
|
|
365
|
+
over-build guard on top without changing the loop.
|
|
366
|
+
|
|
367
|
+
Raises
|
|
368
|
+
------
|
|
369
|
+
CalibError
|
|
370
|
+
If any iteration's solve cannot be trusted (fail-closed).
|
|
371
|
+
"""
|
|
372
|
+
# W — the invest-timeline annualisation weight; a fixed property of the
|
|
373
|
+
# scenario's invest grid, so compute it ONCE and thread it to every
|
|
374
|
+
# sizing step. Reads the DB only here (see _sizing.invest_weight_W).
|
|
375
|
+
W = invest_weight_W(url, scenario)
|
|
376
|
+
|
|
377
|
+
adders: dict[str, float] = {}
|
|
378
|
+
trajectory: list[IterRecord] = []
|
|
379
|
+
prev_record: IterRecord | None = None
|
|
380
|
+
# C1c over-build guard state: the PERSISTENT set of nodes frozen as
|
|
381
|
+
# resource-capped (never bumped again once flagged). The freeze keys off
|
|
382
|
+
# the residual RESPONSE to a bump, not curtailment, so no baseline spill is
|
|
383
|
+
# tracked.
|
|
384
|
+
flagged: set[str] = set()
|
|
385
|
+
converged = False
|
|
386
|
+
# Why the loop stopped; stays "budget_exhausted" unless an exit below
|
|
387
|
+
# sets it (threshold met -> "converged"; no bump possible -> "stalled").
|
|
388
|
+
stop_reason = "budget_exhausted"
|
|
389
|
+
iterations_run = 0
|
|
390
|
+
# How many nodes were bumped by the PRIOR iteration's step; drives the
|
|
391
|
+
# "bumping N node(s)" figure in the next iteration's pre-solve banner
|
|
392
|
+
# (purely observational — see the progress prints below).
|
|
393
|
+
last_bump_count = 0
|
|
394
|
+
|
|
395
|
+
for k in range(config.iterations + 1):
|
|
396
|
+
# Progress banner (streamed to the GUI's live console). k=0 is the
|
|
397
|
+
# baseline; from k=1 on it recalls the prior iteration's unserved and
|
|
398
|
+
# how many nodes were bumped into this solve. print(flush=True) is
|
|
399
|
+
# DELIBERATE: the deep solve dispatch redirects stdout for its own
|
|
400
|
+
# banner and swallows logger.info, so only a flushed print reliably
|
|
401
|
+
# reaches the streamed console.
|
|
402
|
+
if k == 0:
|
|
403
|
+
print(
|
|
404
|
+
f"====== calibrate[{scenario}] iteration "
|
|
405
|
+
f"0/{config.iterations} — baseline solve ======",
|
|
406
|
+
flush=True,
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
prior_unserved = trajectory[-1].total_unserved
|
|
410
|
+
print(
|
|
411
|
+
f"====== calibrate[{scenario}] iteration "
|
|
412
|
+
f"{k}/{config.iterations} — prior unserved "
|
|
413
|
+
f"{prior_unserved:.1f} MWh, bumping {last_bump_count} "
|
|
414
|
+
f"node(s) ======",
|
|
415
|
+
flush=True,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# 1. Materialise the current adders in the calibration alternative
|
|
419
|
+
# (k=0 writes an empty/zero alt so the scenario is solved THROUGH
|
|
420
|
+
# the calib alt from the very first iteration; the alt stack is
|
|
421
|
+
# then constant across the run).
|
|
422
|
+
write_calib_alt(url, scenario, adders)
|
|
423
|
+
|
|
424
|
+
# 2. Give this iteration a clean output dir (archive in debug mode).
|
|
425
|
+
_prepare_out_root(config.out_root, k, config.debug)
|
|
426
|
+
|
|
427
|
+
# 3. Solve, then verify fail-closed (always pass started_at).
|
|
428
|
+
run = run_solve(
|
|
429
|
+
url,
|
|
430
|
+
scenario,
|
|
431
|
+
work_dir=config.work_dir,
|
|
432
|
+
out_root=config.out_root,
|
|
433
|
+
cache_dir=config.warm_start_cache_dir,
|
|
434
|
+
)
|
|
435
|
+
solve_seconds = time.time() - run.started_at
|
|
436
|
+
iterations_run = k + 1
|
|
437
|
+
outcome = assess_solve(
|
|
438
|
+
run.assess_dir,
|
|
439
|
+
exit_code=run.returncode,
|
|
440
|
+
started_at=run.started_at,
|
|
441
|
+
)
|
|
442
|
+
if not outcome.succeeded:
|
|
443
|
+
raise CalibError(
|
|
444
|
+
f"iteration {k} solve not trusted: {outcome.reason}"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# 4. Read the signals and record this iteration.
|
|
448
|
+
residual = read_residual_unserved(run.assess_dir)
|
|
449
|
+
curtailment = read_curtailment_by_sink(run.assess_dir)
|
|
450
|
+
penalty_total, penalty_by_node = read_slack_penalty(run.assess_dir)
|
|
451
|
+
# The per-cell stress profile is only needed for timed sizing.
|
|
452
|
+
dt_slack = (
|
|
453
|
+
_dt_slack_lookup(run.assess_dir)
|
|
454
|
+
if config.sizing == "timed"
|
|
455
|
+
else None
|
|
456
|
+
)
|
|
457
|
+
record = IterRecord(
|
|
458
|
+
iteration=k,
|
|
459
|
+
adders=_copy_adders(adders),
|
|
460
|
+
residual=residual,
|
|
461
|
+
curtailment=curtailment,
|
|
462
|
+
penalty_total=penalty_total,
|
|
463
|
+
penalty_by_node=penalty_by_node,
|
|
464
|
+
solve_seconds=solve_seconds,
|
|
465
|
+
)
|
|
466
|
+
trajectory.append(record)
|
|
467
|
+
|
|
468
|
+
def _emit_summary(newly_flagged_count: int) -> None:
|
|
469
|
+
"""Print this iteration's one-line post-solve summary (streamed)."""
|
|
470
|
+
print(
|
|
471
|
+
f"------ iteration {k}/{config.iterations} done: "
|
|
472
|
+
f"{record.total_unserved:.1f} MWh unserved, "
|
|
473
|
+
f"{record.penalty_total:.1f} M€ penalty, "
|
|
474
|
+
f"{newly_flagged_count} node(s) newly flagged, "
|
|
475
|
+
f"{solve_seconds:.1f} s ------",
|
|
476
|
+
flush=True,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# 5. Converge on total residual unserved energy.
|
|
480
|
+
if record.total_unserved <= config.slack_threshold_mwh:
|
|
481
|
+
converged = True
|
|
482
|
+
stop_reason = "converged"
|
|
483
|
+
_emit_summary(0)
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
# 6. Size the next step, apply the over-build guard, and accumulate —
|
|
487
|
+
# but ONLY when a further solve will VALIDATE it. On the FINAL
|
|
488
|
+
# iteration (k == config.iterations) there is no subsequent solve,
|
|
489
|
+
# so applying an increment here would leave a phantom, unverified
|
|
490
|
+
# adder in final_adders that _report.py would mispair against the
|
|
491
|
+
# LAST solve's residual (the misleading budget_exhausted case).
|
|
492
|
+
# Skipping the step keeps final_adders == the adders actually SOLVED
|
|
493
|
+
# at the last iteration; converged/stalled still break earlier, so
|
|
494
|
+
# only the terminal budget_exhausted path changes.
|
|
495
|
+
if k < config.iterations:
|
|
496
|
+
increments, newly_flagged = compute_step(
|
|
497
|
+
residual, penalty_by_node, prev_record, config,
|
|
498
|
+
W=W, flagged=flagged,
|
|
499
|
+
url=url, scenario=scenario, dt_slack=dt_slack,
|
|
500
|
+
)
|
|
501
|
+
# A newly-flagged node is resource-capped from here on: persist it
|
|
502
|
+
# so no future iteration bumps it again. Union FIRST so the
|
|
503
|
+
# flagged set is complete before the stall check reads it.
|
|
504
|
+
flagged |= newly_flagged
|
|
505
|
+
_emit_summary(len(newly_flagged))
|
|
506
|
+
|
|
507
|
+
# Early stop — STALLED. An empty increment set means no node will
|
|
508
|
+
# be bumped this round: either every remaining shedding node is
|
|
509
|
+
# flagged resource-capped, or no single node exceeds the per-node
|
|
510
|
+
# sizing tolerance. Since the adders are then frozen and the guard
|
|
511
|
+
# set is monotone, the next solve would run on an IDENTICAL model
|
|
512
|
+
# and no future iteration could differ. We are above the threshold
|
|
513
|
+
# (the converge check above did not fire), so this is the
|
|
514
|
+
# "converged modulo the resource-capped nodes" stop: end the run
|
|
515
|
+
# now instead of burning the rest of the --iterations budget on
|
|
516
|
+
# identical solves.
|
|
517
|
+
if not increments:
|
|
518
|
+
stop_reason = "stalled"
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
for node, inc in increments.items():
|
|
522
|
+
_accumulate_adder(adders, node, inc)
|
|
523
|
+
prev_record = record
|
|
524
|
+
last_bump_count = len(increments)
|
|
525
|
+
else:
|
|
526
|
+
# Final iteration: no subsequent solve validates a step, so none is
|
|
527
|
+
# taken — but the solve still happened, so summarise it (no node can
|
|
528
|
+
# be newly flagged here).
|
|
529
|
+
_emit_summary(0)
|
|
530
|
+
|
|
531
|
+
# Final status line reflecting how the loop terminated (streamed).
|
|
532
|
+
print(
|
|
533
|
+
f"====== calibrate[{scenario}] finished: {stop_reason} "
|
|
534
|
+
f"(converged={converged}), {iterations_run} iteration(s), "
|
|
535
|
+
f"{len(flagged)} node(s) flagged resource-capped ======",
|
|
536
|
+
flush=True,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return CalibResult(
|
|
540
|
+
converged=converged,
|
|
541
|
+
stop_reason=stop_reason,
|
|
542
|
+
iterations_run=iterations_run,
|
|
543
|
+
final_adders=dict(adders),
|
|
544
|
+
trajectory=trajectory,
|
|
545
|
+
guard_flagged_nodes=sorted(flagged),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
__all__ = [
|
|
550
|
+
"CalibConfig",
|
|
551
|
+
"CalibError",
|
|
552
|
+
"CalibResult",
|
|
553
|
+
"IterRecord",
|
|
554
|
+
"calib_alt_name",
|
|
555
|
+
"compute_step",
|
|
556
|
+
"guard_freeze",
|
|
557
|
+
"run_calibration",
|
|
558
|
+
]
|