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,1259 @@
|
|
|
1
|
+
"""Non-modal "Calibrate investments" dialog.
|
|
2
|
+
|
|
3
|
+
The dialog gathers the representative-period build knobs and the calibration
|
|
4
|
+
controls for one or more scenarios and turns them into auxiliary jobs via
|
|
5
|
+
:mod:`flextool.gui.calibrate_jobs` — it never renders a command line itself
|
|
6
|
+
(the single argv renderers in :mod:`flextool.gui.calibrate_commands` do that,
|
|
7
|
+
for both the launcher and the live CLI previews, so the previewed text can
|
|
8
|
+
never drift from what runs).
|
|
9
|
+
|
|
10
|
+
Why non-modal
|
|
11
|
+
-------------
|
|
12
|
+
The dialog launches long-running solves that stream into the Execution window;
|
|
13
|
+
the user must be able to watch those while the dialog stays open. It is
|
|
14
|
+
therefore ``transient`` to its parent (stacks with it, no taskbar entry on
|
|
15
|
+
some WMs) but takes NO ``grab_set`` — matching the repo rule that ``grab_set``
|
|
16
|
+
in a Toplevel constructor can abort "not viewable", and the non-modal picker
|
|
17
|
+
precedent (``PlotSettingsPicker``).
|
|
18
|
+
|
|
19
|
+
Two independent tools
|
|
20
|
+
---------------------
|
|
21
|
+
The dialog hosts two separate tools stacked in the same window: a
|
|
22
|
+
representative-periods builder (top) and the adequacy calibrator (bottom).
|
|
23
|
+
They share the scenario set and the solve checklist but are otherwise
|
|
24
|
+
independent — each has its own explanation, its own action button, and its own
|
|
25
|
+
live CLI preview. Both write their result into a NEW alternative, so any change
|
|
26
|
+
made here is undone by deleting that alternative.
|
|
27
|
+
|
|
28
|
+
Dependency injection
|
|
29
|
+
--------------------
|
|
30
|
+
Every external input — the scenarios, the DB-url resolver, the execution
|
|
31
|
+
manager, the python executable, the settings object and its save callback — is
|
|
32
|
+
injected through the constructor, never reached through globals, so the dialog
|
|
33
|
+
can be built headlessly with fakes in a test (see
|
|
34
|
+
``tests/gui/test_calibrate_dialog.py``).
|
|
35
|
+
|
|
36
|
+
xlsx exclusion
|
|
37
|
+
--------------
|
|
38
|
+
RP and calibration both WRITE into the input database; for an xlsx-backed
|
|
39
|
+
scenario the GUI regenerates an intermediate sqlite from the xlsx on every
|
|
40
|
+
run, discarding those writes. So when ANY provided scenario is xlsx-backed the
|
|
41
|
+
action buttons are disabled with an explanatory reason, and the run/RP/copy
|
|
42
|
+
paths defensively skip xlsx scenarios too.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import tkinter as tk
|
|
48
|
+
from collections.abc import Callable, Sequence
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from tkinter import ttk
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from flextool.gui.calibrate_commands import (
|
|
54
|
+
build_calibrate_command,
|
|
55
|
+
build_rp_command,
|
|
56
|
+
command_to_display_string,
|
|
57
|
+
final_write_methods_from_settings,
|
|
58
|
+
overshoot_pct_to_multiplier,
|
|
59
|
+
)
|
|
60
|
+
from flextool.gui.calibrate_jobs import (
|
|
61
|
+
CalibJobSpec,
|
|
62
|
+
RpJobSpec,
|
|
63
|
+
_calib_dirs,
|
|
64
|
+
launch_calibration_jobs,
|
|
65
|
+
launch_rp_jobs,
|
|
66
|
+
)
|
|
67
|
+
from flextool.gui.hover_tooltip import attach_tooltip
|
|
68
|
+
from flextool.gui.solve_reader import read_scenario_solves
|
|
69
|
+
from flextool.representative_periods.scenario_stack import (
|
|
70
|
+
add_alternative_to_scenario,
|
|
71
|
+
create_scenario_with_alternative,
|
|
72
|
+
dedup_alternative_name,
|
|
73
|
+
existing_alternative_names,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
# How long (ms) the action buttons stay disabled after a launch, to swallow
|
|
79
|
+
# an accidental double-click without permanently locking the button (the
|
|
80
|
+
# launcher itself serialises, but a duplicate submit would spawn a duplicate
|
|
81
|
+
# job with the same action_key).
|
|
82
|
+
_GUARD_MS = 2500
|
|
83
|
+
|
|
84
|
+
# Pixel width used to wrap the (verbose) hover tooltips so they never run off
|
|
85
|
+
# the screen edge.
|
|
86
|
+
_TIP_WRAP = 380
|
|
87
|
+
|
|
88
|
+
# ── Above-tool explanations (shown as a paragraph at the top of each tool) ──
|
|
89
|
+
_RP_EXPLANATION = (
|
|
90
|
+
"Representative periods compress a long timeline into a handful of short, "
|
|
91
|
+
"weighted periods that stand in for the whole span. Solving over these "
|
|
92
|
+
"instead of every time step makes investment runs far faster while keeping "
|
|
93
|
+
"the demand and weather patterns that drive the result. This tool clusters "
|
|
94
|
+
"the scenario's profiles and inflows and writes the periods into a NEW "
|
|
95
|
+
"alternative; the option below then decides whether that alternative is "
|
|
96
|
+
"left detached, added to the selected scenario(s) so their solve uses the "
|
|
97
|
+
"new periods, or used to spin up new scenarios. Nothing existing is "
|
|
98
|
+
"overwritten: the change lives entirely in the new alternative (and any "
|
|
99
|
+
"new scenario), so you can undo it any time by deleting them."
|
|
100
|
+
)
|
|
101
|
+
_CALIB_EXPLANATION = (
|
|
102
|
+
"Calibration repeatedly solves each scenario and nudges its energy-margin "
|
|
103
|
+
"adder until the system reaches an adequate capacity margin, so the "
|
|
104
|
+
"resulting investments are neither over- nor under-built. Each iteration "
|
|
105
|
+
"reads the previous solve's shortfall and resizes the margin accordingly. "
|
|
106
|
+
"If you built representative periods above and added them to the scenario, "
|
|
107
|
+
"calibration automatically solves over those periods too — so build them "
|
|
108
|
+
"first for a much faster calibration. The margins are written into a NEW "
|
|
109
|
+
"per-scenario alternative ('<scenario>_adeq_calib') and your original data "
|
|
110
|
+
"is left untouched — so, as with the periods above, you can undo everything "
|
|
111
|
+
"by deleting that alternative."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# ── Per-control hover tooltips (plain-English, verbose) ─────────────────────
|
|
115
|
+
_TIPS = {
|
|
116
|
+
"n_rp": (
|
|
117
|
+
"How many representative periods to keep. More periods reproduce the "
|
|
118
|
+
"original timeline more faithfully but make every solve slower. A "
|
|
119
|
+
"typical starting point is 5–20 (use more periods for better seasonal "
|
|
120
|
+
"representation, but with shorter periods to maintain solve speed)."
|
|
121
|
+
),
|
|
122
|
+
"period_length": (
|
|
123
|
+
"How many time steps each representative period spans (e.g. 24 for a "
|
|
124
|
+
"day, 168 for a week). Longer periods capture within-period storage "
|
|
125
|
+
"and ramping behaviour but leave fewer distinct periods to choose "
|
|
126
|
+
"from."
|
|
127
|
+
),
|
|
128
|
+
"force_sustained": (
|
|
129
|
+
"Also keep the period with the greatest SUSTAINED net load — a long "
|
|
130
|
+
"stretch where demand stays high while wind/solar stay low. Clustering "
|
|
131
|
+
"alone can miss such energy-adequacy stress periods; ticking this "
|
|
132
|
+
"guarantees the worst multi-hour lull is represented, which matters "
|
|
133
|
+
"when sizing storage and firm capacity."
|
|
134
|
+
),
|
|
135
|
+
"force_peak": (
|
|
136
|
+
"Also keep the period containing the single highest INSTANTANEOUS net "
|
|
137
|
+
"load (the peak hour). This protects capacity adequacy — the moment "
|
|
138
|
+
"the system is most likely to fall short — even if that hour would "
|
|
139
|
+
"otherwise be averaged away by clustering."
|
|
140
|
+
),
|
|
141
|
+
"force_window": (
|
|
142
|
+
"The length (in time steps) of the rolling window used to score the "
|
|
143
|
+
"'sustained net load' above. A smaller window reacts to short sharp "
|
|
144
|
+
"lulls; a larger one favours long multi-day droughts. Only relevant "
|
|
145
|
+
"when 'Force highest sustained net load' is ticked."
|
|
146
|
+
),
|
|
147
|
+
"solves": (
|
|
148
|
+
"Which solves get their periods rebuilt. Only these solves have their "
|
|
149
|
+
"'period_timeset' repointed at the new representative periods; other "
|
|
150
|
+
"solves are left untouched. Investment solves are ticked by default "
|
|
151
|
+
"because that is where representative periods usually matter."
|
|
152
|
+
),
|
|
153
|
+
"rp_mode_detached": (
|
|
154
|
+
"Create the new representative-period alternative but attach it to "
|
|
155
|
+
"nothing. The scenarios run exactly as before until you add the "
|
|
156
|
+
"alternative to a scenario yourself later. Deleting the alternative "
|
|
157
|
+
"fully reverts the change."
|
|
158
|
+
),
|
|
159
|
+
"rp_mode_add": (
|
|
160
|
+
"Append the new representative-period alternative to each selected "
|
|
161
|
+
"scenario's alternative stack (at the bottom, so it wins — the "
|
|
162
|
+
"bottommost alternative overrides whatever the ones above it set), "
|
|
163
|
+
"meaning the scenario immediately uses the new periods on its next "
|
|
164
|
+
"run. Deleting the alternative fully reverts the change."
|
|
165
|
+
),
|
|
166
|
+
"rp_mode_new_scenario": (
|
|
167
|
+
"Leave every selected scenario untouched and instead create a NEW "
|
|
168
|
+
"scenario for each — named '<scenario>_<alternative>' — that carries "
|
|
169
|
+
"the same alternative stack plus the new representative-period "
|
|
170
|
+
"alternative on top. Lets you compare the original and the "
|
|
171
|
+
"representative-period runs side by side; delete the new scenario "
|
|
172
|
+
"(and the alternative) to revert."
|
|
173
|
+
),
|
|
174
|
+
"max_iterations": (
|
|
175
|
+
"The maximum number of solve-and-resize rounds the calibrator runs per "
|
|
176
|
+
"scenario. It stops early once the margin is adequate; this is just "
|
|
177
|
+
"the ceiling so a hard-to-satisfy case cannot loop forever."
|
|
178
|
+
),
|
|
179
|
+
"sizing": (
|
|
180
|
+
"How the energy-margin adder is shaped. 'timed' places the extra "
|
|
181
|
+
"margin only in the specific periods/time steps that showed a "
|
|
182
|
+
"shortfall (targeted, usually cheaper). 'uniform' adds the same "
|
|
183
|
+
"constant margin to every time step (simpler, more conservative, might "
|
|
184
|
+
"not converge)."
|
|
185
|
+
),
|
|
186
|
+
"overshoot": (
|
|
187
|
+
"A planning safety margin, in percent, applied on top of the sizing "
|
|
188
|
+
"each iteration. For example 20 means aim 20% above the bare adequate "
|
|
189
|
+
"level, trading a little extra cost for robustness. 0 turns the "
|
|
190
|
+
"overshoot off."
|
|
191
|
+
),
|
|
192
|
+
"damping_first": (
|
|
193
|
+
"How strongly the FIRST iteration moves toward the newly computed "
|
|
194
|
+
"margin (1.0 = take the full step). The first step often has the "
|
|
195
|
+
"furthest to travel, so it is exposed separately from later steps."
|
|
196
|
+
),
|
|
197
|
+
"damping_remaining": (
|
|
198
|
+
"How strongly every iteration AFTER the first moves toward the newly "
|
|
199
|
+
"computed margin (e.g. 0.5 = move halfway each round). Lower values "
|
|
200
|
+
"converge more smoothly and avoid overshoot oscillation; higher values "
|
|
201
|
+
"converge faster but can bounce."
|
|
202
|
+
),
|
|
203
|
+
"stall_fraction": (
|
|
204
|
+
"How sensitive the run is to a resource cap. If an iteration's "
|
|
205
|
+
"shortfall barely improves — changing by less than this fraction — the "
|
|
206
|
+
"calibrator treats the case as capped (e.g. a hard resource or "
|
|
207
|
+
"transmission limit) and stops rather than spending more iterations "
|
|
208
|
+
"chasing a target it cannot reach."
|
|
209
|
+
),
|
|
210
|
+
"keep_artifacts": (
|
|
211
|
+
"Keep the intermediate files (per-iteration solves, logs, warm-start "
|
|
212
|
+
"caches) instead of cleaning them up. Useful for debugging a "
|
|
213
|
+
"calibration that behaves unexpectedly; leave off for normal runs to "
|
|
214
|
+
"save disk space."
|
|
215
|
+
),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _rp_on_success(
|
|
220
|
+
rp_mode: str, db_url: str, scenario_name: str, alt: str
|
|
221
|
+
) -> Callable[[str], None] | None:
|
|
222
|
+
"""Build the post-RP-subprocess hook for the chosen disposition.
|
|
223
|
+
|
|
224
|
+
Returns a ``scenario -> None`` callback run on the worker thread after the
|
|
225
|
+
RP preprocess succeeds, or ``None`` for the detached mode (nothing to do):
|
|
226
|
+
|
|
227
|
+
* ``"add"`` — append the RP alternative onto the scenario's stack;
|
|
228
|
+
* ``"new_scenario"`` — clone the scenario into ``<scenario>_<alt>`` carrying
|
|
229
|
+
the RP alternative, leaving the original untouched;
|
|
230
|
+
* anything else (``"detached"``) — no hook.
|
|
231
|
+
|
|
232
|
+
Kept at module scope (not a per-iteration lambda) so the captured
|
|
233
|
+
``db_url`` / ``alt`` are bound explicitly rather than by late-binding a loop
|
|
234
|
+
variable.
|
|
235
|
+
"""
|
|
236
|
+
if rp_mode == "add":
|
|
237
|
+
def _add(scenario: str) -> None:
|
|
238
|
+
add_alternative_to_scenario(db_url, scenario, alt)
|
|
239
|
+
return _add
|
|
240
|
+
if rp_mode == "new_scenario":
|
|
241
|
+
new_scen = f"{scenario_name}_{alt}"
|
|
242
|
+
def _new(scenario: str) -> None:
|
|
243
|
+
create_scenario_with_alternative(db_url, scenario, new_scen, alt)
|
|
244
|
+
return _new
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class CalibrateDialog(tk.Toplevel):
|
|
249
|
+
"""Non-modal dialog to build representative periods and run calibrations.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
parent:
|
|
254
|
+
Owning widget; the window is ``transient`` to it (and centred on it,
|
|
255
|
+
so it opens on the parent's monitor) but NOT modal.
|
|
256
|
+
scenarios:
|
|
257
|
+
The scenarios to operate on. Each must expose a ``name`` attribute and
|
|
258
|
+
an ``is_xlsx`` attribute (``bool``; xlsx-backed scenarios are excluded
|
|
259
|
+
from running — see the module docstring).
|
|
260
|
+
project_path:
|
|
261
|
+
Project root; passed to the launchers, which derive per-scenario work /
|
|
262
|
+
output directories under it.
|
|
263
|
+
settings:
|
|
264
|
+
The :class:`~flextool.gui.data_models.ProjectSettings` whose ``calib_*``
|
|
265
|
+
fields seed every widget and receive every change.
|
|
266
|
+
execution_mgr:
|
|
267
|
+
The ``ExecutionManager`` the launchers register aux jobs on.
|
|
268
|
+
python_exe:
|
|
269
|
+
Interpreter used to spawn the RP / calibrate subprocesses.
|
|
270
|
+
resolve_db_url:
|
|
271
|
+
``scenario -> db_url`` — resolves a (non-xlsx) scenario object to the
|
|
272
|
+
sqlite URL RP / calibrate read and write.
|
|
273
|
+
save_settings:
|
|
274
|
+
Zero-arg callback persisting ``settings`` to disk; invoked after every
|
|
275
|
+
change and on close.
|
|
276
|
+
show_execution_window:
|
|
277
|
+
Optional zero-arg callback that opens the Execution-jobs window (or
|
|
278
|
+
raises it if already open). Invoked right after an RP / calibration
|
|
279
|
+
launch so the user sees the job stream immediately. ``None`` (the
|
|
280
|
+
headless-test default) skips it.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
parent: tk.Misc,
|
|
286
|
+
*,
|
|
287
|
+
scenarios: Sequence[Any],
|
|
288
|
+
project_path: Path,
|
|
289
|
+
settings: Any,
|
|
290
|
+
execution_mgr: Any,
|
|
291
|
+
python_exe: str,
|
|
292
|
+
resolve_db_url: Callable[[Any], str],
|
|
293
|
+
save_settings: Callable[[], None],
|
|
294
|
+
show_execution_window: Callable[[], None] | None = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
super().__init__(parent)
|
|
297
|
+
self.title("Calibrate investments")
|
|
298
|
+
|
|
299
|
+
self._scenarios = list(scenarios)
|
|
300
|
+
self._project_path = Path(project_path)
|
|
301
|
+
self._settings = settings
|
|
302
|
+
self._execution_mgr = execution_mgr
|
|
303
|
+
self._python_exe = python_exe
|
|
304
|
+
self._resolve_db_url = resolve_db_url
|
|
305
|
+
self._save_settings = save_settings
|
|
306
|
+
self._show_execution_window = show_execution_window
|
|
307
|
+
|
|
308
|
+
# Non-modal: transient (stacks with the parent) but NO grab_set.
|
|
309
|
+
self.transient(parent)
|
|
310
|
+
|
|
311
|
+
# In-flight guards: once a launch fires the matching button is disabled
|
|
312
|
+
# for _GUARD_MS to swallow a double-click, then re-enabled.
|
|
313
|
+
self._rp_guarded = False
|
|
314
|
+
self._calib_guarded = False
|
|
315
|
+
|
|
316
|
+
# Lazily-seeded cache of alternative names already present per db_url,
|
|
317
|
+
# used to de-duplicate the derived RP alternative name (append _2/_3…).
|
|
318
|
+
# An RP launch optimistically records the names it reserves here so the
|
|
319
|
+
# next preview / launch sees them without another DB read.
|
|
320
|
+
self._existing_alts: dict[str, set[str]] = {}
|
|
321
|
+
|
|
322
|
+
# Tk variables (created before traces are wired).
|
|
323
|
+
self._var_n_rp = tk.StringVar()
|
|
324
|
+
self._var_period_length = tk.StringVar()
|
|
325
|
+
self._var_force_sustained = tk.BooleanVar()
|
|
326
|
+
self._var_force_peak = tk.BooleanVar()
|
|
327
|
+
self._var_force_window = tk.StringVar()
|
|
328
|
+
self._var_rp_mode = tk.StringVar()
|
|
329
|
+
self._var_max_iterations = tk.StringVar()
|
|
330
|
+
self._var_sizing = tk.StringVar()
|
|
331
|
+
self._var_overshoot_pct = tk.StringVar()
|
|
332
|
+
self._var_damping_first = tk.StringVar()
|
|
333
|
+
self._var_damping_remaining = tk.StringVar()
|
|
334
|
+
self._var_stall_fraction = tk.StringVar()
|
|
335
|
+
self._var_keep_artifacts = tk.BooleanVar()
|
|
336
|
+
self._reason_var = tk.StringVar(value="")
|
|
337
|
+
|
|
338
|
+
# Solve-checklist BooleanVars, keyed by solve name (union across
|
|
339
|
+
# scenarios), and the union order for stable display.
|
|
340
|
+
self._solve_vars: dict[str, tk.BooleanVar] = {}
|
|
341
|
+
self._solve_order: list[str] = []
|
|
342
|
+
|
|
343
|
+
self._seed_from_settings()
|
|
344
|
+
self._build_solve_selection()
|
|
345
|
+
|
|
346
|
+
self._advanced_visible = tk.BooleanVar(value=False)
|
|
347
|
+
|
|
348
|
+
self._build_widgets()
|
|
349
|
+
self._wire_traces()
|
|
350
|
+
self._refresh_run_state()
|
|
351
|
+
self._refresh_cli_preview()
|
|
352
|
+
|
|
353
|
+
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
354
|
+
self.bind("<Escape>", lambda _e: self._on_close())
|
|
355
|
+
# One wheel handler on the toplevel catches events bubbling up from any
|
|
356
|
+
# descendant (X11 Button-4/5 + Windows/macOS MouseWheel), routed to the
|
|
357
|
+
# body or the solve list by _on_wheel.
|
|
358
|
+
for seq in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
|
|
359
|
+
self.bind(seq, self._on_wheel)
|
|
360
|
+
|
|
361
|
+
# Cap the height to the screen (small-screen guard) and centre on the
|
|
362
|
+
# parent. Guarded: a headless / detached parent may not report geometry.
|
|
363
|
+
self.update_idletasks()
|
|
364
|
+
self._size_and_center(parent)
|
|
365
|
+
|
|
366
|
+
# ── Seed / model helpers ──────────────────────────────────────────
|
|
367
|
+
def _seed_from_settings(self) -> None:
|
|
368
|
+
"""Initialise every scalar widget var from the settings object."""
|
|
369
|
+
s = self._settings
|
|
370
|
+
self._var_n_rp.set(str(s.calib_rp_n_rp))
|
|
371
|
+
self._var_period_length.set(str(s.calib_rp_period_length))
|
|
372
|
+
self._var_force_sustained.set(bool(s.calib_rp_force_sustained))
|
|
373
|
+
self._var_force_peak.set(bool(s.calib_rp_force_peak))
|
|
374
|
+
self._var_force_window.set(str(s.calib_rp_force_window))
|
|
375
|
+
self._var_rp_mode.set(s.calib_rp_scenario_mode or "add")
|
|
376
|
+
self._var_max_iterations.set(str(s.calib_max_iterations))
|
|
377
|
+
self._var_sizing.set(s.calib_sizing or "timed")
|
|
378
|
+
self._var_overshoot_pct.set(str(s.calib_overshoot_pct))
|
|
379
|
+
self._var_damping_first.set(str(s.calib_damping_first))
|
|
380
|
+
self._var_damping_remaining.set(str(s.calib_damping_remaining))
|
|
381
|
+
self._var_stall_fraction.set(str(s.calib_stall_fraction))
|
|
382
|
+
self._var_keep_artifacts.set(bool(s.calib_keep_artifacts))
|
|
383
|
+
|
|
384
|
+
def _build_solve_selection(self) -> None:
|
|
385
|
+
"""Union the solves across scenarios; seed each check state.
|
|
386
|
+
|
|
387
|
+
Same-named solves collapse to one row (first appearance keeps the
|
|
388
|
+
order; ``has_invest_periods`` is OR-ed so an invest solve in any
|
|
389
|
+
scenario is treated as invest). Initial check state: if the solve name
|
|
390
|
+
is already in ``settings.calib_selected_solves`` use that; otherwise
|
|
391
|
+
default-check iff it is an investment solve.
|
|
392
|
+
"""
|
|
393
|
+
prior = set(self._settings.calib_selected_solves or [])
|
|
394
|
+
invest_flag: dict[str, bool] = {}
|
|
395
|
+
# Per-scenario solve names, so the RP launch/preview passes each
|
|
396
|
+
# scenario ONLY the selected solves that actually belong to it — a
|
|
397
|
+
# union-checklist solve from another scenario must never reach a
|
|
398
|
+
# scenario that lacks it (the preprocess errors on such a solve).
|
|
399
|
+
self._scenario_solves: dict[str, list[str]] = {}
|
|
400
|
+
for sc in self._scenarios:
|
|
401
|
+
if getattr(sc, "is_xlsx", False):
|
|
402
|
+
continue
|
|
403
|
+
name = self._scenario_name(sc)
|
|
404
|
+
try:
|
|
405
|
+
url = self._resolve_db_url(sc)
|
|
406
|
+
solves = read_scenario_solves(url, name)
|
|
407
|
+
except Exception as exc: # a bad DB must not break the dialog
|
|
408
|
+
logger.warning(
|
|
409
|
+
"Could not read solves for scenario %r: %s", name, exc,
|
|
410
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
self._scenario_solves[name] = [info.name for info in solves]
|
|
413
|
+
for info in solves:
|
|
414
|
+
if info.name not in invest_flag:
|
|
415
|
+
self._solve_order.append(info.name)
|
|
416
|
+
invest_flag[info.name] = info.has_invest_periods
|
|
417
|
+
else:
|
|
418
|
+
invest_flag[info.name] |= info.has_invest_periods
|
|
419
|
+
|
|
420
|
+
for name in self._solve_order:
|
|
421
|
+
# calib_selected_solves lists only the CHECKED solves, so presence
|
|
422
|
+
# means checked; a never-seen solve defaults to checked iff invest.
|
|
423
|
+
checked = True if name in prior else invest_flag[name]
|
|
424
|
+
self._solve_vars[name] = tk.BooleanVar(value=checked)
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _scenario_name(scenario: Any) -> str:
|
|
428
|
+
return getattr(scenario, "name", str(scenario))
|
|
429
|
+
|
|
430
|
+
def _runnable_scenarios(self) -> list[Any]:
|
|
431
|
+
"""Non-xlsx scenarios — the ones RP / calibrate may write into."""
|
|
432
|
+
return [
|
|
433
|
+
sc for sc in self._scenarios if not getattr(sc, "is_xlsx", False)
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
def _selected_solves(self) -> list[str]:
|
|
437
|
+
return [n for n in self._solve_order if self._solve_vars[n].get()]
|
|
438
|
+
|
|
439
|
+
# ── RP alternative naming ──────────────────────────────────────────
|
|
440
|
+
def _base_alt_name(self, scenario_name: str) -> str:
|
|
441
|
+
"""Derive the RP alternative base name (before de-duplication).
|
|
442
|
+
|
|
443
|
+
Keyed on the SCENARIO name, plus the representative-period count and
|
|
444
|
+
the period length, e.g. ``coal_rp_40rp_54h`` for scenario ``coal``,
|
|
445
|
+
40 periods of 54 steps. The scenario — not the solve — is the
|
|
446
|
+
informative discriminator: each scenario is clustered separately from
|
|
447
|
+
its own VRE/demand series, so its representative periods are scenario-
|
|
448
|
+
specific. The solve(s) the periods were built for are recorded in the
|
|
449
|
+
description instead. Distinct scenarios never collide (names are
|
|
450
|
+
unique); a repeat build of the SAME scenario/count/length de-dups to
|
|
451
|
+
``_2``/``_3``.
|
|
452
|
+
"""
|
|
453
|
+
s = self._settings
|
|
454
|
+
return (
|
|
455
|
+
f"{scenario_name}_rp_{s.calib_rp_n_rp}rp_"
|
|
456
|
+
f"{s.calib_rp_period_length}h"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _alt_description(self, scenario_name: str, solves: Sequence[str]) -> str:
|
|
460
|
+
"""Verbose description written onto the RP alternative."""
|
|
461
|
+
s = self._settings
|
|
462
|
+
if solves:
|
|
463
|
+
solve_txt = "solve(s) " + ", ".join(f"'{x}'" for x in solves)
|
|
464
|
+
else:
|
|
465
|
+
solve_txt = "all solves carrying a period_timeset"
|
|
466
|
+
return (
|
|
467
|
+
"Representative periods generated with the Calibrate-investments "
|
|
468
|
+
f"tool for scenario '{scenario_name}': {s.calib_rp_n_rp} "
|
|
469
|
+
f"representative period(s) of {s.calib_rp_period_length} time steps "
|
|
470
|
+
f"each, built for {solve_txt}. This alternative can be deleted to "
|
|
471
|
+
"undo the change."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _taken_alt_names(self, db_url: str) -> set[str]:
|
|
475
|
+
"""Cached set of alternative names already in *db_url* (lazy-seeded)."""
|
|
476
|
+
if db_url not in self._existing_alts:
|
|
477
|
+
try:
|
|
478
|
+
self._existing_alts[db_url] = existing_alternative_names(db_url)
|
|
479
|
+
except Exception as exc: # a bad DB must not break the preview
|
|
480
|
+
logger.warning(
|
|
481
|
+
"Could not read alternatives for %s: %s", db_url, exc
|
|
482
|
+
)
|
|
483
|
+
self._existing_alts[db_url] = set()
|
|
484
|
+
return self._existing_alts[db_url]
|
|
485
|
+
|
|
486
|
+
def _applicable_solves(self, scenario_name: str) -> list[str]:
|
|
487
|
+
"""Selected solves that actually belong to *scenario_name* (in order).
|
|
488
|
+
|
|
489
|
+
The solve checklist is the UNION across scenarios; a scenario only
|
|
490
|
+
gets the selected solves it actually runs. A solve selected but absent
|
|
491
|
+
from this scenario is dropped, so the RP preprocess never receives a
|
|
492
|
+
solve it cannot repoint.
|
|
493
|
+
"""
|
|
494
|
+
selected = set(self._selected_solves())
|
|
495
|
+
scen = set(self._scenario_solves.get(scenario_name, []))
|
|
496
|
+
return [n for n in self._solve_order if n in selected and n in scen]
|
|
497
|
+
|
|
498
|
+
def _rp_plan(
|
|
499
|
+
self, *, commit: bool
|
|
500
|
+
) -> list[tuple[Any, str, str, list[str], str, str]]:
|
|
501
|
+
"""Plan one RP job per runnable scenario that has an applicable solve.
|
|
502
|
+
|
|
503
|
+
Returns ``[(scenario, name, db_url, applicable_solves, alt_name,
|
|
504
|
+
description), …]``. A scenario contributes an entry ONLY when at least
|
|
505
|
+
one selected solve belongs to it — so with no matching solve the
|
|
506
|
+
scenario is skipped rather than launched with a solve it cannot use.
|
|
507
|
+
|
|
508
|
+
The alternative name encodes that scenario's applicable solve(s) and is
|
|
509
|
+
de-duplicated (``_2``/``_3``) against the per-db cache; names are
|
|
510
|
+
allocated sequentially through a per-db overlay so two scenarios that
|
|
511
|
+
share a database and a base name get ``base`` then ``base_2``. With
|
|
512
|
+
``commit`` the reserved names are folded into the persistent cache so
|
|
513
|
+
the next preview / launch sees them (the launch path); the preview
|
|
514
|
+
passes ``commit=False`` so merely looking never reserves.
|
|
515
|
+
"""
|
|
516
|
+
overlay: dict[str, set[str]] = {}
|
|
517
|
+
plan: list[tuple[Any, str, str, list[str], str, str]] = []
|
|
518
|
+
for sc in self._runnable_scenarios():
|
|
519
|
+
name = self._scenario_name(sc)
|
|
520
|
+
applicable = self._applicable_solves(name)
|
|
521
|
+
if not applicable:
|
|
522
|
+
continue
|
|
523
|
+
db_url = self._resolve_db_url(sc)
|
|
524
|
+
base = self._base_alt_name(name)
|
|
525
|
+
taken = set(self._taken_alt_names(db_url)) | overlay.get(db_url, set())
|
|
526
|
+
alt = dedup_alternative_name(base, taken)
|
|
527
|
+
overlay.setdefault(db_url, set()).add(alt)
|
|
528
|
+
desc = self._alt_description(name, applicable)
|
|
529
|
+
plan.append((sc, name, db_url, applicable, alt, desc))
|
|
530
|
+
if commit:
|
|
531
|
+
for db_url, names in overlay.items():
|
|
532
|
+
self._existing_alts.setdefault(db_url, set()).update(names)
|
|
533
|
+
return plan
|
|
534
|
+
|
|
535
|
+
# ── Layout ────────────────────────────────────────────────────────
|
|
536
|
+
def _build_widgets(self) -> None:
|
|
537
|
+
pad = dict(padx=10, pady=(6, 0))
|
|
538
|
+
|
|
539
|
+
# Fixed footer (reason + Close) pinned to the bottom OUTSIDE the scroll
|
|
540
|
+
# region, so the Close button is always reachable however tall the body
|
|
541
|
+
# grows. Packed first (side=bottom) to reserve its space.
|
|
542
|
+
self._footer_area = ttk.Frame(self)
|
|
543
|
+
self._footer_area.pack(side="bottom", fill="x")
|
|
544
|
+
ttk.Label(
|
|
545
|
+
self._footer_area, textvariable=self._reason_var,
|
|
546
|
+
foreground="gray", wraplength=560, justify="left",
|
|
547
|
+
).pack(fill="x", padx=10, pady=(6, 4))
|
|
548
|
+
close_row = ttk.Frame(self._footer_area)
|
|
549
|
+
close_row.pack(fill="x", padx=10, pady=(0, 6))
|
|
550
|
+
ttk.Button(close_row, text="Close", command=self._on_close).pack(
|
|
551
|
+
side="right"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# The body scrolls vertically inside a height-capped window — the fixed
|
|
555
|
+
# content (explanations + previews) alone can exceed a 1024-tall screen.
|
|
556
|
+
outer = self._build_scroll_body()
|
|
557
|
+
|
|
558
|
+
# ── 1) Representative periods ─────────────────────────────────
|
|
559
|
+
rp = ttk.LabelFrame(
|
|
560
|
+
outer, text="1 · Build representative periods (optional)"
|
|
561
|
+
)
|
|
562
|
+
rp.pack(fill="x", **pad)
|
|
563
|
+
|
|
564
|
+
# No explicit foreground — a hardcoded gray vanishes against a dark
|
|
565
|
+
# theme; the default label colour tracks the theme in both modes.
|
|
566
|
+
ttk.Label(
|
|
567
|
+
rp, text=_RP_EXPLANATION, wraplength=560, justify="left",
|
|
568
|
+
).pack(fill="x", padx=8, pady=(8, 4))
|
|
569
|
+
|
|
570
|
+
row = ttk.Frame(rp)
|
|
571
|
+
row.pack(fill="x", padx=8, pady=(4, 4))
|
|
572
|
+
n_lbl = ttk.Label(row, text="Periods (n_rp):")
|
|
573
|
+
n_lbl.pack(side="left")
|
|
574
|
+
n_ent = ttk.Entry(row, textvariable=self._var_n_rp, width=8)
|
|
575
|
+
n_ent.pack(side="left", padx=(4, 16))
|
|
576
|
+
pl_lbl = ttk.Label(row, text="Period length (steps):")
|
|
577
|
+
pl_lbl.pack(side="left")
|
|
578
|
+
pl_ent = ttk.Entry(row, textvariable=self._var_period_length, width=8)
|
|
579
|
+
pl_ent.pack(side="left", padx=(4, 0))
|
|
580
|
+
for w in (n_lbl, n_ent):
|
|
581
|
+
attach_tooltip(w, _TIPS["n_rp"], wraplength=_TIP_WRAP)
|
|
582
|
+
for w in (pl_lbl, pl_ent):
|
|
583
|
+
attach_tooltip(w, _TIPS["period_length"], wraplength=_TIP_WRAP)
|
|
584
|
+
|
|
585
|
+
frow = ttk.Frame(rp)
|
|
586
|
+
frow.pack(fill="x", padx=8, pady=4)
|
|
587
|
+
cb_sust = ttk.Checkbutton(
|
|
588
|
+
frow, text="Force highest sustained net load",
|
|
589
|
+
variable=self._var_force_sustained,
|
|
590
|
+
)
|
|
591
|
+
cb_sust.pack(side="left")
|
|
592
|
+
cb_peak = ttk.Checkbutton(
|
|
593
|
+
frow, text="Force instantaneous peak",
|
|
594
|
+
variable=self._var_force_peak,
|
|
595
|
+
)
|
|
596
|
+
cb_peak.pack(side="left", padx=(16, 0))
|
|
597
|
+
attach_tooltip(cb_sust, _TIPS["force_sustained"], wraplength=_TIP_WRAP)
|
|
598
|
+
attach_tooltip(cb_peak, _TIPS["force_peak"], wraplength=_TIP_WRAP)
|
|
599
|
+
|
|
600
|
+
wrow = ttk.Frame(rp)
|
|
601
|
+
wrow.pack(fill="x", padx=8, pady=4)
|
|
602
|
+
w_lbl = ttk.Label(wrow, text="Window:")
|
|
603
|
+
w_lbl.pack(side="left")
|
|
604
|
+
w_ent = ttk.Entry(wrow, textvariable=self._var_force_window, width=8)
|
|
605
|
+
w_ent.pack(side="left", padx=(4, 16))
|
|
606
|
+
for w in (w_lbl, w_ent):
|
|
607
|
+
attach_tooltip(w, _TIPS["force_window"], wraplength=_TIP_WRAP)
|
|
608
|
+
|
|
609
|
+
# Solve checklist (bounded, scrollable — a long solve list must not
|
|
610
|
+
# push the dialog past small-screen height).
|
|
611
|
+
solves_lbl = ttk.Label(rp, text="Solves to (re)build periods for:")
|
|
612
|
+
solves_lbl.pack(anchor="w", padx=8, pady=(6, 0))
|
|
613
|
+
attach_tooltip(solves_lbl, _TIPS["solves"], wraplength=_TIP_WRAP)
|
|
614
|
+
self._build_solve_checklist(rp)
|
|
615
|
+
|
|
616
|
+
# Three-way disposition of the freshly built RP alternative.
|
|
617
|
+
rb_detached = ttk.Radiobutton(
|
|
618
|
+
rp,
|
|
619
|
+
text="Just make new alternative with the new representative periods",
|
|
620
|
+
value="detached", variable=self._var_rp_mode,
|
|
621
|
+
)
|
|
622
|
+
rb_detached.pack(anchor="w", padx=8, pady=(4, 0))
|
|
623
|
+
attach_tooltip(
|
|
624
|
+
rb_detached, _TIPS["rp_mode_detached"], wraplength=_TIP_WRAP
|
|
625
|
+
)
|
|
626
|
+
rb_add = ttk.Radiobutton(
|
|
627
|
+
rp,
|
|
628
|
+
text="Add the new alternative to the selected scenario(s)",
|
|
629
|
+
value="add", variable=self._var_rp_mode,
|
|
630
|
+
)
|
|
631
|
+
rb_add.pack(anchor="w", padx=8, pady=(2, 0))
|
|
632
|
+
attach_tooltip(rb_add, _TIPS["rp_mode_add"], wraplength=_TIP_WRAP)
|
|
633
|
+
rb_new = ttk.Radiobutton(
|
|
634
|
+
rp,
|
|
635
|
+
text="Make new scenario(s) with the new alternative",
|
|
636
|
+
value="new_scenario", variable=self._var_rp_mode,
|
|
637
|
+
)
|
|
638
|
+
rb_new.pack(anchor="w", padx=8, pady=(2, 2))
|
|
639
|
+
attach_tooltip(
|
|
640
|
+
rb_new, _TIPS["rp_mode_new_scenario"], wraplength=_TIP_WRAP
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
self._rp_button = ttk.Button(
|
|
644
|
+
rp, text="Create new representative periods",
|
|
645
|
+
command=self._on_create_rp,
|
|
646
|
+
)
|
|
647
|
+
self._rp_button.pack(anchor="w", padx=8, pady=(4, 4))
|
|
648
|
+
|
|
649
|
+
# RP CLI preview (live). The commands are typically far wider than the
|
|
650
|
+
# dialog, so the Text is wrap="none" with a horizontal scrollbar.
|
|
651
|
+
self._rp_cli_text = self._make_cli_preview(
|
|
652
|
+
rp, "Representative-periods CLI command:", self._on_copy_rp
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# ── separator between the two tools ───────────────────────────
|
|
656
|
+
ttk.Separator(outer, orient="horizontal").pack(
|
|
657
|
+
fill="x", padx=10, pady=10
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# ── 2) Calibration settings ───────────────────────────────────
|
|
661
|
+
cal = ttk.LabelFrame(outer, text="2 · Calibrate investments")
|
|
662
|
+
cal.pack(fill="x", **pad)
|
|
663
|
+
|
|
664
|
+
ttk.Label(
|
|
665
|
+
cal, text=_CALIB_EXPLANATION, wraplength=560, justify="left",
|
|
666
|
+
).pack(fill="x", padx=8, pady=(8, 4))
|
|
667
|
+
|
|
668
|
+
crow = ttk.Frame(cal)
|
|
669
|
+
crow.pack(fill="x", padx=8, pady=(4, 4))
|
|
670
|
+
it_lbl = ttk.Label(crow, text="Max. iterations:")
|
|
671
|
+
it_lbl.pack(side="left")
|
|
672
|
+
it_ent = ttk.Entry(crow, textvariable=self._var_max_iterations, width=8)
|
|
673
|
+
it_ent.pack(side="left", padx=(4, 16))
|
|
674
|
+
for w in (it_lbl, it_ent):
|
|
675
|
+
attach_tooltip(w, _TIPS["max_iterations"], wraplength=_TIP_WRAP)
|
|
676
|
+
sz_lbl = ttk.Label(crow, text="Sizing:")
|
|
677
|
+
sz_lbl.pack(side="left")
|
|
678
|
+
rb_timed = ttk.Radiobutton(
|
|
679
|
+
crow, text="timed", value="timed", variable=self._var_sizing,
|
|
680
|
+
)
|
|
681
|
+
rb_timed.pack(side="left", padx=(4, 0))
|
|
682
|
+
rb_uniform = ttk.Radiobutton(
|
|
683
|
+
crow, text="uniform", value="uniform", variable=self._var_sizing,
|
|
684
|
+
)
|
|
685
|
+
rb_uniform.pack(side="left", padx=(4, 0))
|
|
686
|
+
for w in (sz_lbl, rb_timed, rb_uniform):
|
|
687
|
+
attach_tooltip(w, _TIPS["sizing"], wraplength=_TIP_WRAP)
|
|
688
|
+
|
|
689
|
+
self._advanced_button = ttk.Button(
|
|
690
|
+
cal, text="Advanced ▸", width=14,
|
|
691
|
+
command=self._toggle_advanced,
|
|
692
|
+
)
|
|
693
|
+
self._advanced_button.pack(anchor="w", padx=8, pady=(4, 2))
|
|
694
|
+
|
|
695
|
+
self._advanced_frame = ttk.Frame(cal)
|
|
696
|
+
# Not packed yet — shown by _toggle_advanced.
|
|
697
|
+
self._build_advanced(self._advanced_frame)
|
|
698
|
+
|
|
699
|
+
cb_keep = ttk.Checkbutton(
|
|
700
|
+
cal, text="Keep per-iteration artifacts",
|
|
701
|
+
variable=self._var_keep_artifacts,
|
|
702
|
+
)
|
|
703
|
+
cb_keep.pack(anchor="w", padx=8, pady=(4, 2))
|
|
704
|
+
attach_tooltip(cb_keep, _TIPS["keep_artifacts"], wraplength=_TIP_WRAP)
|
|
705
|
+
|
|
706
|
+
self._run_button = ttk.Button(
|
|
707
|
+
cal, text="Run calibrations", command=self._on_run,
|
|
708
|
+
)
|
|
709
|
+
self._run_button.pack(anchor="w", padx=8, pady=(4, 4))
|
|
710
|
+
|
|
711
|
+
# Calibration CLI preview (live), same wide-scrolling treatment.
|
|
712
|
+
self._calib_cli_text = self._make_cli_preview(
|
|
713
|
+
cal, "Calibration CLI command:", self._on_copy_calib
|
|
714
|
+
)
|
|
715
|
+
# (The reason label + Close button live in the fixed footer, built at
|
|
716
|
+
# the top of this method outside the scroll region.)
|
|
717
|
+
|
|
718
|
+
def _build_scroll_body(self) -> ttk.Frame:
|
|
719
|
+
"""Create the vertically-scrollable body and return its inner frame.
|
|
720
|
+
|
|
721
|
+
A canvas hosts an inner ``ttk.Frame`` (returned) that every section is
|
|
722
|
+
packed into; a vertical scrollbar and the shared wheel handler
|
|
723
|
+
(``_on_wheel``) scroll it. The inner frame is kept exactly as wide as
|
|
724
|
+
the canvas so nothing needs horizontal scrolling at the dialog level
|
|
725
|
+
(the CLI previews scroll horizontally within themselves).
|
|
726
|
+
"""
|
|
727
|
+
holder = ttk.Frame(self)
|
|
728
|
+
holder.pack(side="top", fill="both", expand=True)
|
|
729
|
+
canvas = tk.Canvas(holder, highlightthickness=0)
|
|
730
|
+
vsb = ttk.Scrollbar(holder, orient="vertical", command=canvas.yview)
|
|
731
|
+
canvas.configure(yscrollcommand=vsb.set)
|
|
732
|
+
vsb.pack(side="right", fill="y")
|
|
733
|
+
canvas.pack(side="left", fill="both", expand=True)
|
|
734
|
+
|
|
735
|
+
inner = ttk.Frame(canvas, padding=10)
|
|
736
|
+
win = canvas.create_window((0, 0), window=inner, anchor="nw")
|
|
737
|
+
inner.bind(
|
|
738
|
+
"<Configure>",
|
|
739
|
+
lambda _e: canvas.configure(scrollregion=canvas.bbox("all")),
|
|
740
|
+
)
|
|
741
|
+
canvas.bind(
|
|
742
|
+
"<Configure>", lambda e: canvas.itemconfigure(win, width=e.width)
|
|
743
|
+
)
|
|
744
|
+
self._body_canvas = canvas
|
|
745
|
+
self._scroll_inner = inner
|
|
746
|
+
return inner
|
|
747
|
+
|
|
748
|
+
def _on_wheel(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
749
|
+
"""Route a wheel event to the solve list if over it, else the body.
|
|
750
|
+
|
|
751
|
+
Bound once on the toplevel (so it catches wheel events bubbling up from
|
|
752
|
+
any descendant) — no per-widget Enter/Leave juggling. Handles both the
|
|
753
|
+
X11 ``Button-4/5`` events and the ``MouseWheel`` ``delta`` of
|
|
754
|
+
Windows/macOS.
|
|
755
|
+
"""
|
|
756
|
+
num = getattr(event, "num", 0)
|
|
757
|
+
if num == 4:
|
|
758
|
+
step = -1
|
|
759
|
+
elif num == 5:
|
|
760
|
+
step = 1
|
|
761
|
+
else:
|
|
762
|
+
step = -int(event.delta / 120)
|
|
763
|
+
target = self._body_canvas
|
|
764
|
+
node = getattr(event, "widget", None)
|
|
765
|
+
solve_canvas = getattr(self, "_solve_canvas", None)
|
|
766
|
+
while node is not None:
|
|
767
|
+
if node is solve_canvas:
|
|
768
|
+
target = solve_canvas
|
|
769
|
+
break
|
|
770
|
+
node = getattr(node, "master", None)
|
|
771
|
+
if target is not None:
|
|
772
|
+
target.yview_scroll(step, "units")
|
|
773
|
+
|
|
774
|
+
def _build_advanced(self, frame: ttk.Frame) -> None:
|
|
775
|
+
"""Build the (initially hidden) advanced-knobs subframe."""
|
|
776
|
+
def _num_row(label: str, var: tk.StringVar, tip: str) -> None:
|
|
777
|
+
r = ttk.Frame(frame)
|
|
778
|
+
r.pack(fill="x", padx=8, pady=2)
|
|
779
|
+
lbl = ttk.Label(r, text=label, width=26, anchor="w")
|
|
780
|
+
lbl.pack(side="left")
|
|
781
|
+
ent = ttk.Entry(r, textvariable=var, width=10)
|
|
782
|
+
ent.pack(side="left")
|
|
783
|
+
for w in (lbl, ent):
|
|
784
|
+
attach_tooltip(w, tip, wraplength=_TIP_WRAP)
|
|
785
|
+
|
|
786
|
+
_num_row(
|
|
787
|
+
"Planning safety margin (%):", self._var_overshoot_pct,
|
|
788
|
+
_TIPS["overshoot"],
|
|
789
|
+
)
|
|
790
|
+
_num_row("Damping first:", self._var_damping_first, _TIPS["damping_first"])
|
|
791
|
+
_num_row(
|
|
792
|
+
"Damping remaining:", self._var_damping_remaining,
|
|
793
|
+
_TIPS["damping_remaining"],
|
|
794
|
+
)
|
|
795
|
+
_num_row(
|
|
796
|
+
"Resource-cap sensitivity:", self._var_stall_fraction,
|
|
797
|
+
_TIPS["stall_fraction"],
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def _toggle_advanced(self) -> None:
|
|
801
|
+
if self._advanced_visible.get():
|
|
802
|
+
self._advanced_frame.pack_forget()
|
|
803
|
+
self._advanced_visible.set(False)
|
|
804
|
+
self._advanced_button.configure(text="Advanced ▸")
|
|
805
|
+
else:
|
|
806
|
+
self._advanced_frame.pack(
|
|
807
|
+
fill="x", after=self._advanced_button, padx=0, pady=(0, 4)
|
|
808
|
+
)
|
|
809
|
+
self._advanced_visible.set(True)
|
|
810
|
+
self._advanced_button.configure(text="Advanced ▾")
|
|
811
|
+
|
|
812
|
+
# Max solve rows shown before the checklist starts scrolling.
|
|
813
|
+
_MAX_SOLVE_ROWS = 5
|
|
814
|
+
|
|
815
|
+
def _build_solve_checklist(self, parent: tk.Misc) -> None:
|
|
816
|
+
"""Render the solve checklist inside a height-bounded, scrollable box.
|
|
817
|
+
|
|
818
|
+
The list shows at most ``_MAX_SOLVE_ROWS`` solves; beyond that a
|
|
819
|
+
vertical scrollbar appears and the box stops growing, so a scenario
|
|
820
|
+
with many solves can never stretch the dialog past a small screen.
|
|
821
|
+
"""
|
|
822
|
+
if not self._solve_order:
|
|
823
|
+
ttk.Label(
|
|
824
|
+
parent, text="(no solves found for the selected scenarios)",
|
|
825
|
+
foreground="gray",
|
|
826
|
+
).pack(anchor="w", padx=16, pady=(2, 4))
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
from flextool.gui.ui_metrics import get_metrics
|
|
830
|
+
|
|
831
|
+
m = get_metrics(self)
|
|
832
|
+
visible = min(len(self._solve_order), self._MAX_SOLVE_ROWS)
|
|
833
|
+
overflow = len(self._solve_order) > self._MAX_SOLVE_ROWS
|
|
834
|
+
|
|
835
|
+
container = ttk.Frame(parent)
|
|
836
|
+
container.pack(fill="x", padx=16, pady=(2, 4))
|
|
837
|
+
canvas = tk.Canvas(
|
|
838
|
+
container, highlightthickness=0, height=m.row_height * visible,
|
|
839
|
+
)
|
|
840
|
+
canvas.pack(side="left", fill="x", expand=True)
|
|
841
|
+
sb = ttk.Scrollbar(
|
|
842
|
+
container, orient="vertical", command=canvas.yview
|
|
843
|
+
)
|
|
844
|
+
canvas.configure(yscrollcommand=sb.set)
|
|
845
|
+
|
|
846
|
+
rows = ttk.Frame(canvas)
|
|
847
|
+
win = canvas.create_window((0, 0), window=rows, anchor="nw")
|
|
848
|
+
# Keep the scrollregion current and the inner frame as wide as the
|
|
849
|
+
# canvas (so the checkbuttons fill the row and hover reads naturally).
|
|
850
|
+
rows.bind(
|
|
851
|
+
"<Configure>",
|
|
852
|
+
lambda _e: canvas.configure(scrollregion=canvas.bbox("all")),
|
|
853
|
+
)
|
|
854
|
+
canvas.bind(
|
|
855
|
+
"<Configure>",
|
|
856
|
+
lambda e: canvas.itemconfigure(win, width=e.width),
|
|
857
|
+
)
|
|
858
|
+
# The shared toplevel wheel handler (_on_wheel) scrolls this canvas
|
|
859
|
+
# when the pointer is over it — no per-widget wheel binding needed.
|
|
860
|
+
self._solve_canvas = canvas
|
|
861
|
+
|
|
862
|
+
for name in self._solve_order:
|
|
863
|
+
cb = ttk.Checkbutton(
|
|
864
|
+
rows, text=name, variable=self._solve_vars[name],
|
|
865
|
+
)
|
|
866
|
+
cb.pack(anchor="w", fill="x")
|
|
867
|
+
attach_tooltip(cb, _TIPS["solves"], wraplength=_TIP_WRAP)
|
|
868
|
+
|
|
869
|
+
# The scrollbar is only useful (and only shown) when the list overflows.
|
|
870
|
+
if overflow:
|
|
871
|
+
sb.pack(side="right", fill="y")
|
|
872
|
+
|
|
873
|
+
def _make_cli_preview(
|
|
874
|
+
self, parent: tk.Misc, label: str, copy_cmd: Callable[[], None]
|
|
875
|
+
) -> tk.Text:
|
|
876
|
+
"""Build a read-only, horizontally-scrollable CLI-preview + Copy button.
|
|
877
|
+
|
|
878
|
+
Returns the disabled :class:`tk.Text` widget so the caller can keep a
|
|
879
|
+
handle for live refreshes. ``wrap="none"`` plus the horizontal
|
|
880
|
+
scrollbar lets the (usually very long) command lines scroll instead of
|
|
881
|
+
being clipped by the dialog width.
|
|
882
|
+
"""
|
|
883
|
+
ttk.Label(parent, text=label).pack(anchor="w", padx=8)
|
|
884
|
+
row = ttk.Frame(parent)
|
|
885
|
+
row.pack(fill="both", expand=True, padx=8, pady=(2, 8))
|
|
886
|
+
text_frame = ttk.Frame(row)
|
|
887
|
+
text_frame.pack(side="left", fill="both", expand=True)
|
|
888
|
+
text = tk.Text(text_frame, height=3, wrap="none")
|
|
889
|
+
hsb = ttk.Scrollbar(
|
|
890
|
+
text_frame, orient="horizontal", command=text.xview
|
|
891
|
+
)
|
|
892
|
+
text.configure(xscrollcommand=hsb.set)
|
|
893
|
+
text.pack(side="top", fill="both", expand=True)
|
|
894
|
+
hsb.pack(side="bottom", fill="x")
|
|
895
|
+
text.configure(state="disabled")
|
|
896
|
+
ttk.Button(row, text="Copy", width=8, command=copy_cmd).pack(
|
|
897
|
+
side="left", padx=(6, 0), anchor="n"
|
|
898
|
+
)
|
|
899
|
+
return text
|
|
900
|
+
|
|
901
|
+
# Vertical margin left free below/above the window on a small screen
|
|
902
|
+
# (title bar + panel/taskbar) when the content would otherwise fill it.
|
|
903
|
+
_SCREEN_V_MARGIN = 96
|
|
904
|
+
|
|
905
|
+
def _size_and_center(self, parent: tk.Misc) -> None:
|
|
906
|
+
"""Size the window to content but cap the height to the screen.
|
|
907
|
+
|
|
908
|
+
The content width is used as-is; the height is ``min(content, screen -
|
|
909
|
+
margin)`` so on a short screen (≈1024 px) the body scrolls instead of
|
|
910
|
+
the window running off the top and bottom. The window is then centred
|
|
911
|
+
on the parent (placing it on the parent's monitor).
|
|
912
|
+
|
|
913
|
+
The body lives inside a Canvas, which does NOT propagate its scrolled
|
|
914
|
+
content's requested size to the toplevel — so the natural size is
|
|
915
|
+
measured from the inner content frame plus the fixed footer, not from
|
|
916
|
+
``self.winfo_reqheight()`` (which would report only the canvas default).
|
|
917
|
+
"""
|
|
918
|
+
try:
|
|
919
|
+
# +18 px leaves room for the vertical scrollbar beside the canvas.
|
|
920
|
+
dw = self._scroll_inner.winfo_reqwidth() + 18
|
|
921
|
+
content_h = (
|
|
922
|
+
self._scroll_inner.winfo_reqheight()
|
|
923
|
+
+ self._footer_area.winfo_reqheight()
|
|
924
|
+
)
|
|
925
|
+
screen_h = self.winfo_screenheight()
|
|
926
|
+
cap_h = min(content_h, max(400, screen_h - self._SCREEN_V_MARGIN))
|
|
927
|
+
self.geometry(f"{dw}x{cap_h}")
|
|
928
|
+
except tk.TclError:
|
|
929
|
+
return
|
|
930
|
+
try:
|
|
931
|
+
px = parent.winfo_rootx()
|
|
932
|
+
py = parent.winfo_rooty()
|
|
933
|
+
pw = parent.winfo_width()
|
|
934
|
+
ph = parent.winfo_height()
|
|
935
|
+
x = px + (pw - dw) // 2
|
|
936
|
+
y = max(0, py + (ph - cap_h) // 2)
|
|
937
|
+
self.geometry(f"+{x}+{y}")
|
|
938
|
+
except tk.TclError:
|
|
939
|
+
pass
|
|
940
|
+
|
|
941
|
+
# ── Persistence ───────────────────────────────────────────────────
|
|
942
|
+
def _wire_traces(self) -> None:
|
|
943
|
+
"""Flush to settings on every widget change (and, later, on close)."""
|
|
944
|
+
every = [
|
|
945
|
+
self._var_n_rp, self._var_period_length, self._var_force_sustained,
|
|
946
|
+
self._var_force_peak, self._var_force_window,
|
|
947
|
+
self._var_rp_mode,
|
|
948
|
+
self._var_max_iterations, self._var_sizing, self._var_overshoot_pct,
|
|
949
|
+
self._var_damping_first, self._var_damping_remaining,
|
|
950
|
+
self._var_stall_fraction, self._var_keep_artifacts,
|
|
951
|
+
]
|
|
952
|
+
for var in (*every, *self._solve_vars.values()):
|
|
953
|
+
var.trace_add("write", lambda *_a: self._flush())
|
|
954
|
+
|
|
955
|
+
@staticmethod
|
|
956
|
+
def _as_int(var: tk.StringVar, fallback: int) -> int:
|
|
957
|
+
"""Parse *var* as int, keeping *fallback* on partial / bad input."""
|
|
958
|
+
try:
|
|
959
|
+
return int(float(var.get().strip()))
|
|
960
|
+
except (ValueError, tk.TclError):
|
|
961
|
+
return fallback
|
|
962
|
+
|
|
963
|
+
@staticmethod
|
|
964
|
+
def _as_float(var: tk.StringVar, fallback: float) -> float:
|
|
965
|
+
try:
|
|
966
|
+
return float(var.get().strip())
|
|
967
|
+
except (ValueError, tk.TclError):
|
|
968
|
+
return fallback
|
|
969
|
+
|
|
970
|
+
def _flush(self) -> None:
|
|
971
|
+
"""Read every widget into ``settings``, persist, and refresh previews.
|
|
972
|
+
|
|
973
|
+
Numeric fields tolerate mid-edit / empty text by keeping the last good
|
|
974
|
+
value, so a partially typed entry never wipes a setting.
|
|
975
|
+
"""
|
|
976
|
+
s = self._settings
|
|
977
|
+
s.calib_rp_n_rp = self._as_int(self._var_n_rp, s.calib_rp_n_rp)
|
|
978
|
+
s.calib_rp_period_length = self._as_int(
|
|
979
|
+
self._var_period_length, s.calib_rp_period_length
|
|
980
|
+
)
|
|
981
|
+
s.calib_rp_force_sustained = bool(self._var_force_sustained.get())
|
|
982
|
+
s.calib_rp_force_peak = bool(self._var_force_peak.get())
|
|
983
|
+
s.calib_rp_force_window = self._as_int(
|
|
984
|
+
self._var_force_window, s.calib_rp_force_window
|
|
985
|
+
)
|
|
986
|
+
rp_mode = self._var_rp_mode.get()
|
|
987
|
+
if rp_mode in ("detached", "add", "new_scenario"):
|
|
988
|
+
s.calib_rp_scenario_mode = rp_mode
|
|
989
|
+
s.calib_max_iterations = self._as_int(
|
|
990
|
+
self._var_max_iterations, s.calib_max_iterations
|
|
991
|
+
)
|
|
992
|
+
sizing = self._var_sizing.get()
|
|
993
|
+
if sizing in ("timed", "uniform"):
|
|
994
|
+
s.calib_sizing = sizing
|
|
995
|
+
s.calib_overshoot_pct = self._as_float(
|
|
996
|
+
self._var_overshoot_pct, s.calib_overshoot_pct
|
|
997
|
+
)
|
|
998
|
+
s.calib_damping_first = self._as_float(
|
|
999
|
+
self._var_damping_first, s.calib_damping_first
|
|
1000
|
+
)
|
|
1001
|
+
s.calib_damping_remaining = self._as_float(
|
|
1002
|
+
self._var_damping_remaining, s.calib_damping_remaining
|
|
1003
|
+
)
|
|
1004
|
+
s.calib_stall_fraction = self._as_float(
|
|
1005
|
+
self._var_stall_fraction, s.calib_stall_fraction
|
|
1006
|
+
)
|
|
1007
|
+
s.calib_keep_artifacts = bool(self._var_keep_artifacts.get())
|
|
1008
|
+
s.calib_selected_solves = self._selected_solves()
|
|
1009
|
+
|
|
1010
|
+
self._refresh_cli_preview()
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
self._save_settings()
|
|
1014
|
+
except Exception: # persistence must never break the UI
|
|
1015
|
+
logger.exception("Saving calibrate settings failed")
|
|
1016
|
+
|
|
1017
|
+
# ── Enable / disable ──────────────────────────────────────────────
|
|
1018
|
+
def _refresh_run_state(self) -> None:
|
|
1019
|
+
"""Set the disabled reason and the button states.
|
|
1020
|
+
|
|
1021
|
+
Both action buttons are disabled when there are no scenarios or ANY
|
|
1022
|
+
scenario is xlsx-backed (its DB writes would be discarded). The
|
|
1023
|
+
per-action in-flight guard disables only the just-clicked button.
|
|
1024
|
+
"""
|
|
1025
|
+
xlsx = [
|
|
1026
|
+
self._scenario_name(sc)
|
|
1027
|
+
for sc in self._scenarios
|
|
1028
|
+
if getattr(sc, "is_xlsx", False)
|
|
1029
|
+
]
|
|
1030
|
+
if not self._scenarios:
|
|
1031
|
+
reason = "No scenarios selected — nothing to calibrate."
|
|
1032
|
+
elif xlsx:
|
|
1033
|
+
reason = (
|
|
1034
|
+
"Disabled: xlsx-backed scenario(s) "
|
|
1035
|
+
f"{', '.join(xlsx)} regenerate their database on each run, "
|
|
1036
|
+
"which would discard the representative-period / calibration "
|
|
1037
|
+
"writes. Convert them to a Spine database first."
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
reason = ""
|
|
1041
|
+
self._reason_var.set(reason)
|
|
1042
|
+
|
|
1043
|
+
base_enabled = reason == ""
|
|
1044
|
+
self._rp_button.configure(
|
|
1045
|
+
state="normal" if base_enabled and not self._rp_guarded
|
|
1046
|
+
else "disabled"
|
|
1047
|
+
)
|
|
1048
|
+
self._run_button.configure(
|
|
1049
|
+
state="normal" if base_enabled and not self._calib_guarded
|
|
1050
|
+
else "disabled"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
def _guard_rp(self) -> None:
|
|
1054
|
+
self._rp_guarded = True
|
|
1055
|
+
self._refresh_run_state()
|
|
1056
|
+
self.after(_GUARD_MS, self._unguard_rp)
|
|
1057
|
+
|
|
1058
|
+
def _unguard_rp(self) -> None:
|
|
1059
|
+
self._rp_guarded = False
|
|
1060
|
+
if self.winfo_exists():
|
|
1061
|
+
self._refresh_run_state()
|
|
1062
|
+
|
|
1063
|
+
def _guard_calib(self) -> None:
|
|
1064
|
+
self._calib_guarded = True
|
|
1065
|
+
self._refresh_run_state()
|
|
1066
|
+
self.after(_GUARD_MS, self._unguard_calib)
|
|
1067
|
+
|
|
1068
|
+
def _unguard_calib(self) -> None:
|
|
1069
|
+
self._calib_guarded = False
|
|
1070
|
+
if self.winfo_exists():
|
|
1071
|
+
self._refresh_run_state()
|
|
1072
|
+
|
|
1073
|
+
def _reveal_execution_window(self) -> None:
|
|
1074
|
+
"""Open / raise the Execution-jobs window so the launch is visible."""
|
|
1075
|
+
if self._show_execution_window is None:
|
|
1076
|
+
return
|
|
1077
|
+
try:
|
|
1078
|
+
self._show_execution_window()
|
|
1079
|
+
except Exception: # never let a UI-raise failure break the launch
|
|
1080
|
+
logger.exception("Opening the execution window failed")
|
|
1081
|
+
|
|
1082
|
+
# ── RP flow ───────────────────────────────────────────────────────
|
|
1083
|
+
def _on_create_rp(self) -> None:
|
|
1084
|
+
"""Launch one RP-preprocess job per runnable scenario.
|
|
1085
|
+
|
|
1086
|
+
Each job carries an ``on_success(scenario)`` hook whose behaviour
|
|
1087
|
+
follows the disposition radio; it fires AFTER the RP subprocess
|
|
1088
|
+
finishes successfully, on the worker thread:
|
|
1089
|
+
|
|
1090
|
+
* ``"detached"`` — no hook; the alternative is created but attached to
|
|
1091
|
+
nothing.
|
|
1092
|
+
* ``"add"`` — append the freshly written RP alternative onto that
|
|
1093
|
+
scenario's stack so a subsequent calibration sees the new periods.
|
|
1094
|
+
* ``"new_scenario"`` — clone the scenario into a new
|
|
1095
|
+
``"<scenario>_<alternative>"`` scenario carrying the RP alternative,
|
|
1096
|
+
leaving the original untouched.
|
|
1097
|
+
"""
|
|
1098
|
+
self._flush()
|
|
1099
|
+
s = self._settings
|
|
1100
|
+
rp_mode = self._var_rp_mode.get()
|
|
1101
|
+
# Reserve (commit) the per-scenario names so the preview immediately
|
|
1102
|
+
# advances (base → base_2) and a second launch cannot collide.
|
|
1103
|
+
plan = self._rp_plan(commit=True)
|
|
1104
|
+
if not plan:
|
|
1105
|
+
return
|
|
1106
|
+
specs: list[RpJobSpec] = []
|
|
1107
|
+
for _sc, name, db_url, applicable, alt, desc in plan:
|
|
1108
|
+
on_success = _rp_on_success(rp_mode, db_url, name, alt)
|
|
1109
|
+
specs.append(
|
|
1110
|
+
RpJobSpec(
|
|
1111
|
+
db_url=db_url,
|
|
1112
|
+
scenario=name,
|
|
1113
|
+
n_rp=s.calib_rp_n_rp,
|
|
1114
|
+
period_length=s.calib_rp_period_length,
|
|
1115
|
+
force_sustained=s.calib_rp_force_sustained,
|
|
1116
|
+
force_peak=s.calib_rp_force_peak,
|
|
1117
|
+
force_window=s.calib_rp_force_window,
|
|
1118
|
+
solves=applicable,
|
|
1119
|
+
alternative_name=alt,
|
|
1120
|
+
alternative_description=desc,
|
|
1121
|
+
on_success=on_success,
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
launch_rp_jobs(
|
|
1125
|
+
self._execution_mgr,
|
|
1126
|
+
python_exe=self._python_exe,
|
|
1127
|
+
project_path=self._project_path,
|
|
1128
|
+
jobs=specs,
|
|
1129
|
+
)
|
|
1130
|
+
self._guard_rp()
|
|
1131
|
+
self._reveal_execution_window()
|
|
1132
|
+
self._refresh_cli_preview()
|
|
1133
|
+
|
|
1134
|
+
# ── Run flow ──────────────────────────────────────────────────────
|
|
1135
|
+
def _on_run(self) -> None:
|
|
1136
|
+
"""Launch one calibration job per runnable scenario."""
|
|
1137
|
+
self._flush()
|
|
1138
|
+
runnable = self._runnable_scenarios()
|
|
1139
|
+
if not runnable:
|
|
1140
|
+
return
|
|
1141
|
+
s = self._settings
|
|
1142
|
+
specs: list[CalibJobSpec] = []
|
|
1143
|
+
for sc in runnable:
|
|
1144
|
+
specs.append(
|
|
1145
|
+
CalibJobSpec(
|
|
1146
|
+
db_url=self._resolve_db_url(sc),
|
|
1147
|
+
scenario=self._scenario_name(sc),
|
|
1148
|
+
iterations=s.calib_max_iterations,
|
|
1149
|
+
sizing=s.calib_sizing,
|
|
1150
|
+
overshoot_pct=s.calib_overshoot_pct,
|
|
1151
|
+
damping_first=s.calib_damping_first,
|
|
1152
|
+
damping_remaining=s.calib_damping_remaining,
|
|
1153
|
+
stall_fraction=s.calib_stall_fraction,
|
|
1154
|
+
debug=s.calib_keep_artifacts,
|
|
1155
|
+
# Regenerate the same formats a regular run would (the
|
|
1156
|
+
# project's "File outputs" choices), from the final parquet.
|
|
1157
|
+
final_write_methods=final_write_methods_from_settings(s),
|
|
1158
|
+
)
|
|
1159
|
+
)
|
|
1160
|
+
launch_calibration_jobs(
|
|
1161
|
+
self._execution_mgr,
|
|
1162
|
+
python_exe=self._python_exe,
|
|
1163
|
+
project_path=self._project_path,
|
|
1164
|
+
jobs=specs,
|
|
1165
|
+
)
|
|
1166
|
+
self._guard_calib()
|
|
1167
|
+
self._reveal_execution_window()
|
|
1168
|
+
|
|
1169
|
+
# ── CLI previews ──────────────────────────────────────────────────
|
|
1170
|
+
def _render_rp_cli(self) -> str:
|
|
1171
|
+
"""Render the RP command(s) exactly as "Create…" would run them."""
|
|
1172
|
+
s = self._settings
|
|
1173
|
+
plan = self._rp_plan(commit=False)
|
|
1174
|
+
if not plan:
|
|
1175
|
+
# Distinguish "no runnable scenario" from "no selected solve
|
|
1176
|
+
# applies" so the empty preview is not mysterious.
|
|
1177
|
+
if self._runnable_scenarios():
|
|
1178
|
+
return (
|
|
1179
|
+
"# No selected solve belongs to the selected scenario(s) — "
|
|
1180
|
+
"tick a solve that the scenario actually runs."
|
|
1181
|
+
)
|
|
1182
|
+
return ""
|
|
1183
|
+
lines: list[str] = []
|
|
1184
|
+
for _sc, name, db_url, applicable, alt, desc in plan:
|
|
1185
|
+
argv = build_rp_command(
|
|
1186
|
+
self._python_exe, db_url, name,
|
|
1187
|
+
n_rp=s.calib_rp_n_rp,
|
|
1188
|
+
period_length=s.calib_rp_period_length,
|
|
1189
|
+
force_sustained=s.calib_rp_force_sustained,
|
|
1190
|
+
force_peak=s.calib_rp_force_peak,
|
|
1191
|
+
force_window=s.calib_rp_force_window,
|
|
1192
|
+
solves=applicable,
|
|
1193
|
+
alternative_name=alt,
|
|
1194
|
+
alternative_description=desc,
|
|
1195
|
+
)
|
|
1196
|
+
lines.append(command_to_display_string(argv))
|
|
1197
|
+
return "\n".join(lines)
|
|
1198
|
+
|
|
1199
|
+
def _render_calib_cli(self) -> str:
|
|
1200
|
+
"""Render the calibrate command(s) exactly as "Run…" would run them."""
|
|
1201
|
+
s = self._settings
|
|
1202
|
+
lines: list[str] = []
|
|
1203
|
+
for sc in self._runnable_scenarios():
|
|
1204
|
+
name = self._scenario_name(sc)
|
|
1205
|
+
db_url = self._resolve_db_url(sc)
|
|
1206
|
+
# create=False: previewing must not litter empty work directories.
|
|
1207
|
+
warm, work, out = _calib_dirs(
|
|
1208
|
+
self._project_path, name, create=False
|
|
1209
|
+
)
|
|
1210
|
+
argv = build_calibrate_command(
|
|
1211
|
+
self._python_exe, db_url, name,
|
|
1212
|
+
iterations=s.calib_max_iterations,
|
|
1213
|
+
sizing=s.calib_sizing,
|
|
1214
|
+
overshoot=overshoot_pct_to_multiplier(s.calib_overshoot_pct),
|
|
1215
|
+
damping_first=s.calib_damping_first,
|
|
1216
|
+
damping_remaining=s.calib_damping_remaining,
|
|
1217
|
+
stall_fraction=s.calib_stall_fraction,
|
|
1218
|
+
warm_start_cache_dir=warm,
|
|
1219
|
+
work_dir=work,
|
|
1220
|
+
output_location=out,
|
|
1221
|
+
debug=s.calib_keep_artifacts,
|
|
1222
|
+
final_write_methods=final_write_methods_from_settings(s),
|
|
1223
|
+
)
|
|
1224
|
+
lines.append(command_to_display_string(argv))
|
|
1225
|
+
return "\n".join(lines)
|
|
1226
|
+
|
|
1227
|
+
@staticmethod
|
|
1228
|
+
def _set_text(widget: tk.Text, text: str) -> None:
|
|
1229
|
+
widget.configure(state="normal")
|
|
1230
|
+
widget.delete("1.0", "end")
|
|
1231
|
+
widget.insert("1.0", text)
|
|
1232
|
+
widget.configure(state="disabled")
|
|
1233
|
+
|
|
1234
|
+
def _refresh_cli_preview(self) -> None:
|
|
1235
|
+
"""Re-render both live CLI previews from the current settings."""
|
|
1236
|
+
# Guard: called from _flush, which can fire via a trace before the text
|
|
1237
|
+
# widgets are built.
|
|
1238
|
+
if not hasattr(self, "_rp_cli_text"):
|
|
1239
|
+
return
|
|
1240
|
+
try:
|
|
1241
|
+
self._set_text(self._rp_cli_text, self._render_rp_cli())
|
|
1242
|
+
self._set_text(self._calib_cli_text, self._render_calib_cli())
|
|
1243
|
+
except tk.TclError:
|
|
1244
|
+
pass
|
|
1245
|
+
|
|
1246
|
+
def _on_copy_rp(self) -> None:
|
|
1247
|
+
self._flush()
|
|
1248
|
+
self.clipboard_clear()
|
|
1249
|
+
self.clipboard_append(self._render_rp_cli())
|
|
1250
|
+
|
|
1251
|
+
def _on_copy_calib(self) -> None:
|
|
1252
|
+
self._flush()
|
|
1253
|
+
self.clipboard_clear()
|
|
1254
|
+
self.clipboard_append(self._render_calib_cli())
|
|
1255
|
+
|
|
1256
|
+
# ── Close ─────────────────────────────────────────────────────────
|
|
1257
|
+
def _on_close(self) -> None:
|
|
1258
|
+
self._flush()
|
|
1259
|
+
self.destroy()
|