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,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from flextool.gui.data_models import (
|
|
9
|
+
ExecutionLimits,
|
|
10
|
+
GlobalSettings,
|
|
11
|
+
PlotSettings,
|
|
12
|
+
ProjectSettings,
|
|
13
|
+
ScenarioRun,
|
|
14
|
+
SourceRecord,
|
|
15
|
+
ViewerSettings,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SETTINGS_FILENAME = "settings.yaml"
|
|
19
|
+
GLOBAL_SETTINGS_FILENAME = "projects.yaml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _as_geometry_map(raw: object) -> dict[str, str]:
|
|
23
|
+
"""Normalise a saved geometry field into a {signature -> geometry} map.
|
|
24
|
+
|
|
25
|
+
Accepts the new dict form, or a legacy plain string (from before
|
|
26
|
+
per-monitor-configuration memory) which is migrated under the
|
|
27
|
+
``"legacy"`` key — used as a layout-agnostic fallback until the window
|
|
28
|
+
is next closed and re-saved under a concrete signature. Anything else
|
|
29
|
+
yields an empty map.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(raw, dict):
|
|
32
|
+
return {str(k): str(v) for k, v in raw.items() if isinstance(v, str) and v}
|
|
33
|
+
if isinstance(raw, str) and raw:
|
|
34
|
+
return {"legacy": raw}
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_project_settings(project_path: Path) -> ProjectSettings:
|
|
39
|
+
"""Load project settings from settings.yaml in the project directory.
|
|
40
|
+
|
|
41
|
+
Returns defaults if the file does not exist or cannot be parsed.
|
|
42
|
+
"""
|
|
43
|
+
settings_file = project_path / SETTINGS_FILENAME
|
|
44
|
+
if not settings_file.exists():
|
|
45
|
+
return ProjectSettings()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with open(settings_file, "r", encoding="utf-8") as f:
|
|
49
|
+
data = yaml.safe_load(f)
|
|
50
|
+
except (yaml.YAMLError, OSError):
|
|
51
|
+
return ProjectSettings()
|
|
52
|
+
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
return ProjectSettings()
|
|
55
|
+
|
|
56
|
+
settings = ProjectSettings()
|
|
57
|
+
settings.auto_generate_scen_plots = data.get(
|
|
58
|
+
"auto_generate_scen_plots", settings.auto_generate_scen_plots
|
|
59
|
+
)
|
|
60
|
+
settings.auto_generate_scen_excels = data.get(
|
|
61
|
+
"auto_generate_scen_excels", settings.auto_generate_scen_excels
|
|
62
|
+
)
|
|
63
|
+
settings.auto_generate_scen_csvs = data.get(
|
|
64
|
+
"auto_generate_scen_csvs", settings.auto_generate_scen_csvs
|
|
65
|
+
)
|
|
66
|
+
settings.auto_generate_comp_plots = data.get(
|
|
67
|
+
"auto_generate_comp_plots", settings.auto_generate_comp_plots
|
|
68
|
+
)
|
|
69
|
+
settings.auto_generate_comp_excel = data.get(
|
|
70
|
+
"auto_generate_comp_excel", settings.auto_generate_comp_excel
|
|
71
|
+
)
|
|
72
|
+
settings.auto_generate_comp_spinedb = data.get(
|
|
73
|
+
"auto_generate_comp_spinedb", settings.auto_generate_comp_spinedb
|
|
74
|
+
)
|
|
75
|
+
# Legacy compat: pre-tiered ``debug: bool`` settings.yaml entries
|
|
76
|
+
# map True→"full" (preserves their old behaviour: tracemalloc +
|
|
77
|
+
# csv-dump) and False→"off". New entries use ``debug_level``
|
|
78
|
+
# directly.
|
|
79
|
+
if "debug_level" in data:
|
|
80
|
+
_level = data.get("debug_level", settings.debug_level)
|
|
81
|
+
if _level not in ("off", "basic", "full"):
|
|
82
|
+
_level = "off"
|
|
83
|
+
settings.debug_level = _level
|
|
84
|
+
elif "debug" in data:
|
|
85
|
+
settings.debug_level = "full" if bool(data["debug"]) else "off"
|
|
86
|
+
settings.save_memory = bool(data.get("save_memory", settings.save_memory))
|
|
87
|
+
|
|
88
|
+
# Solver options — validate each against its allowed set / type and
|
|
89
|
+
# fall back to the dataclass default on anything malformed so a
|
|
90
|
+
# hand-edited settings.yaml can't break the GUI.
|
|
91
|
+
_sll = data.get("solver_log_level", settings.solver_log_level)
|
|
92
|
+
if _sll in ("silent", "normal", "verbose"):
|
|
93
|
+
settings.solver_log_level = _sll
|
|
94
|
+
_stl = data.get("solver_time_limit", settings.solver_time_limit)
|
|
95
|
+
if isinstance(_stl, int) and not isinstance(_stl, bool) and _stl >= 0:
|
|
96
|
+
settings.solver_time_limit = _stl
|
|
97
|
+
_smg = data.get("solver_mip_gap", settings.solver_mip_gap)
|
|
98
|
+
if isinstance(_smg, (int, float)) and not isinstance(_smg, bool) and _smg >= 0:
|
|
99
|
+
settings.solver_mip_gap = float(_smg)
|
|
100
|
+
_smg_set = data.get("solver_mip_gap_set", settings.solver_mip_gap_set)
|
|
101
|
+
if isinstance(_smg_set, bool):
|
|
102
|
+
settings.solver_mip_gap_set = _smg_set
|
|
103
|
+
_mff = data.get("matrix_file_format", settings.matrix_file_format)
|
|
104
|
+
if _mff in ("mps", "lp"):
|
|
105
|
+
settings.matrix_file_format = _mff
|
|
106
|
+
_scl = data.get("scaling", settings.scaling)
|
|
107
|
+
if _scl in ("off", "solver_only", "basic", "full"):
|
|
108
|
+
settings.scaling = _scl
|
|
109
|
+
_ps = data.get("presolve", settings.presolve)
|
|
110
|
+
if _ps in ("on", "off", "choose"):
|
|
111
|
+
settings.presolve = _ps
|
|
112
|
+
|
|
113
|
+
# Calibrate-investments dialog — validate each field against its
|
|
114
|
+
# allowed set / type and fall back to the dataclass default on
|
|
115
|
+
# anything malformed so a hand-edited settings.yaml can't break the
|
|
116
|
+
# GUI. bool is an int subclass, so ints/floats exclude it explicitly.
|
|
117
|
+
_c_n_rp = data.get("calib_rp_n_rp", settings.calib_rp_n_rp)
|
|
118
|
+
if isinstance(_c_n_rp, int) and not isinstance(_c_n_rp, bool):
|
|
119
|
+
settings.calib_rp_n_rp = _c_n_rp
|
|
120
|
+
_c_plen = data.get("calib_rp_period_length", settings.calib_rp_period_length)
|
|
121
|
+
if isinstance(_c_plen, int) and not isinstance(_c_plen, bool):
|
|
122
|
+
settings.calib_rp_period_length = _c_plen
|
|
123
|
+
_c_sust = data.get("calib_rp_force_sustained", settings.calib_rp_force_sustained)
|
|
124
|
+
if isinstance(_c_sust, bool):
|
|
125
|
+
settings.calib_rp_force_sustained = _c_sust
|
|
126
|
+
_c_peak = data.get("calib_rp_force_peak", settings.calib_rp_force_peak)
|
|
127
|
+
if isinstance(_c_peak, bool):
|
|
128
|
+
settings.calib_rp_force_peak = _c_peak
|
|
129
|
+
_c_win = data.get("calib_rp_force_window", settings.calib_rp_force_window)
|
|
130
|
+
if isinstance(_c_win, int) and not isinstance(_c_win, bool):
|
|
131
|
+
settings.calib_rp_force_window = _c_win
|
|
132
|
+
_c_mode = data.get("calib_rp_scenario_mode")
|
|
133
|
+
if isinstance(_c_mode, str) and _c_mode in ("detached", "add", "new_scenario"):
|
|
134
|
+
settings.calib_rp_scenario_mode = _c_mode
|
|
135
|
+
else:
|
|
136
|
+
# Legacy migration: the pre-3-way boolean "add to scenario" flag
|
|
137
|
+
# (True -> append to the scenario, False -> leave the alt detached).
|
|
138
|
+
_c_add = data.get("calib_rp_add_to_scenario")
|
|
139
|
+
if isinstance(_c_add, bool):
|
|
140
|
+
settings.calib_rp_scenario_mode = "add" if _c_add else "detached"
|
|
141
|
+
_c_solves = data.get("calib_selected_solves", settings.calib_selected_solves)
|
|
142
|
+
if isinstance(_c_solves, list):
|
|
143
|
+
settings.calib_selected_solves = [
|
|
144
|
+
s for s in _c_solves if isinstance(s, str) and s
|
|
145
|
+
]
|
|
146
|
+
_c_iter = data.get("calib_max_iterations", settings.calib_max_iterations)
|
|
147
|
+
if isinstance(_c_iter, int) and not isinstance(_c_iter, bool):
|
|
148
|
+
settings.calib_max_iterations = _c_iter
|
|
149
|
+
_c_sizing = data.get("calib_sizing", settings.calib_sizing)
|
|
150
|
+
if _c_sizing in ("timed", "uniform"):
|
|
151
|
+
settings.calib_sizing = _c_sizing
|
|
152
|
+
_c_over = data.get("calib_overshoot_pct", settings.calib_overshoot_pct)
|
|
153
|
+
if isinstance(_c_over, (int, float)) and not isinstance(_c_over, bool):
|
|
154
|
+
settings.calib_overshoot_pct = float(_c_over)
|
|
155
|
+
_c_damp1 = data.get("calib_damping_first", settings.calib_damping_first)
|
|
156
|
+
if isinstance(_c_damp1, (int, float)) and not isinstance(_c_damp1, bool):
|
|
157
|
+
settings.calib_damping_first = float(_c_damp1)
|
|
158
|
+
_c_dampr = data.get("calib_damping_remaining", settings.calib_damping_remaining)
|
|
159
|
+
if isinstance(_c_dampr, (int, float)) and not isinstance(_c_dampr, bool):
|
|
160
|
+
settings.calib_damping_remaining = float(_c_dampr)
|
|
161
|
+
_c_stall = data.get("calib_stall_fraction", settings.calib_stall_fraction)
|
|
162
|
+
if isinstance(_c_stall, (int, float)) and not isinstance(_c_stall, bool):
|
|
163
|
+
settings.calib_stall_fraction = float(_c_stall)
|
|
164
|
+
_c_keep = data.get("calib_keep_artifacts", settings.calib_keep_artifacts)
|
|
165
|
+
if isinstance(_c_keep, bool):
|
|
166
|
+
settings.calib_keep_artifacts = _c_keep
|
|
167
|
+
|
|
168
|
+
settings.input_source_numbers = data.get(
|
|
169
|
+
"input_source_numbers", settings.input_source_numbers
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Per-number source identity. Keys are stringified numbers; values are
|
|
173
|
+
# {name, path} dicts. Tolerate a hand-edited / malformed map by
|
|
174
|
+
# validating each entry and dropping the rest.
|
|
175
|
+
registry_data = data.get("source_registry", {})
|
|
176
|
+
if isinstance(registry_data, dict):
|
|
177
|
+
registry: dict[str, SourceRecord] = {}
|
|
178
|
+
for num_key, rec in registry_data.items():
|
|
179
|
+
key = str(num_key)
|
|
180
|
+
if not key.isdigit():
|
|
181
|
+
continue
|
|
182
|
+
if isinstance(rec, dict):
|
|
183
|
+
registry[key] = SourceRecord(
|
|
184
|
+
name=str(rec.get("name", "")),
|
|
185
|
+
path=str(rec.get("path", "")),
|
|
186
|
+
)
|
|
187
|
+
settings.source_registry = registry
|
|
188
|
+
|
|
189
|
+
settings.external_refs = data.get("external_refs", settings.external_refs)
|
|
190
|
+
settings.bare_output_owners = data.get(
|
|
191
|
+
"bare_output_owners", settings.bare_output_owners
|
|
192
|
+
)
|
|
193
|
+
settings.scenario_order = data.get("scenario_order", settings.scenario_order)
|
|
194
|
+
settings.executed_scenario_order = data.get(
|
|
195
|
+
"executed_scenario_order", settings.executed_scenario_order
|
|
196
|
+
)
|
|
197
|
+
settings.comp_plots_scenarios = data.get("comp_plots_scenarios", [])
|
|
198
|
+
settings.comp_excel_scenarios = data.get("comp_excel_scenarios", [])
|
|
199
|
+
settings.comp_viewer_scenarios = data.get("comp_viewer_scenarios", [])
|
|
200
|
+
settings.checked_input_sources = data.get("checked_input_sources", [])
|
|
201
|
+
settings.checked_available_scenarios = data.get("checked_available_scenarios", [])
|
|
202
|
+
settings.checked_executed_scenarios = data.get("checked_executed_scenarios", [])
|
|
203
|
+
|
|
204
|
+
def _clean_variant_durations(raw: object) -> dict[str, int]:
|
|
205
|
+
if not isinstance(raw, dict):
|
|
206
|
+
return {}
|
|
207
|
+
cleaned: dict[str, int] = {}
|
|
208
|
+
for k, v in raw.items():
|
|
209
|
+
if isinstance(v, bool):
|
|
210
|
+
# bool is an int subclass — exclude explicitly
|
|
211
|
+
continue
|
|
212
|
+
if isinstance(v, (int, float)):
|
|
213
|
+
cleaned[str(k)] = int(v)
|
|
214
|
+
return cleaned
|
|
215
|
+
|
|
216
|
+
single_plot = data.get("single_plot_settings")
|
|
217
|
+
if isinstance(single_plot, dict):
|
|
218
|
+
settings.single_plot_settings = PlotSettings(
|
|
219
|
+
start_time=single_plot.get("start_time", 0),
|
|
220
|
+
duration=single_plot.get("duration", 0),
|
|
221
|
+
config_file=single_plot.get("config_file", ""),
|
|
222
|
+
active_configs=single_plot.get("active_configs", []),
|
|
223
|
+
only_first_file=single_plot.get("only_first_file", False),
|
|
224
|
+
variant_durations=_clean_variant_durations(
|
|
225
|
+
single_plot.get("variant_durations", {})
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
comp_plot = data.get("comparison_plot_settings")
|
|
230
|
+
if isinstance(comp_plot, dict):
|
|
231
|
+
settings.comparison_plot_settings = PlotSettings(
|
|
232
|
+
start_time=comp_plot.get("start_time", 0),
|
|
233
|
+
duration=comp_plot.get("duration", 0),
|
|
234
|
+
config_file=comp_plot.get("config_file", ""),
|
|
235
|
+
active_configs=comp_plot.get("active_configs", []),
|
|
236
|
+
dispatch_plots=comp_plot.get("dispatch_plots", True),
|
|
237
|
+
only_first_file=comp_plot.get("only_first_file", False),
|
|
238
|
+
variant_durations=_clean_variant_durations(
|
|
239
|
+
comp_plot.get("variant_durations", {})
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
viewer = data.get("viewer_settings")
|
|
244
|
+
if isinstance(viewer, dict):
|
|
245
|
+
settings.viewer_settings = ViewerSettings(
|
|
246
|
+
last_scenario=viewer.get("last_scenario", ""),
|
|
247
|
+
last_entry=viewer.get("last_entry", ""),
|
|
248
|
+
last_variant=viewer.get("last_variant", ""),
|
|
249
|
+
last_mode=viewer.get("last_mode", "single"),
|
|
250
|
+
window_geometry=_as_geometry_map(viewer.get("window_geometry")),
|
|
251
|
+
left_pane_width=viewer.get("left_pane_width", 0),
|
|
252
|
+
scenario_pane_height=viewer.get("scenario_pane_height", 0),
|
|
253
|
+
layout_cw=viewer.get("layout_cw", 0),
|
|
254
|
+
cache_gb=viewer.get("cache_gb", 0.5),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
history_data = data.get("scenario_resource_history", {})
|
|
258
|
+
if isinstance(history_data, dict):
|
|
259
|
+
history: dict[str, ScenarioRun] = {}
|
|
260
|
+
for subdir, run_data in history_data.items():
|
|
261
|
+
if not isinstance(run_data, dict):
|
|
262
|
+
continue
|
|
263
|
+
history[str(subdir)] = ScenarioRun(
|
|
264
|
+
peak_rss_mb=float(run_data.get("peak_rss_mb", 0.0)),
|
|
265
|
+
runtime_s=float(run_data.get("runtime_s", 0.0)),
|
|
266
|
+
last_run=str(run_data.get("last_run", "")),
|
|
267
|
+
)
|
|
268
|
+
settings.scenario_resource_history = history
|
|
269
|
+
|
|
270
|
+
limits_data = data.get("execution_limits")
|
|
271
|
+
if isinstance(limits_data, dict):
|
|
272
|
+
settings.execution_limits = ExecutionLimits(
|
|
273
|
+
max_cores_per_job=int(limits_data.get(
|
|
274
|
+
"max_cores_per_job", settings.execution_limits.max_cores_per_job
|
|
275
|
+
)),
|
|
276
|
+
memory_cap_per_job_gb=float(limits_data.get(
|
|
277
|
+
"memory_cap_per_job_gb", settings.execution_limits.memory_cap_per_job_gb
|
|
278
|
+
)),
|
|
279
|
+
system_reserve_gb=float(limits_data.get(
|
|
280
|
+
"system_reserve_gb", settings.execution_limits.system_reserve_gb
|
|
281
|
+
)),
|
|
282
|
+
swap_allowance_gb=float(limits_data.get(
|
|
283
|
+
"swap_allowance_gb", settings.execution_limits.swap_allowance_gb
|
|
284
|
+
)),
|
|
285
|
+
)
|
|
286
|
+
mw = data.get("max_workers")
|
|
287
|
+
if isinstance(mw, int) and mw > 0:
|
|
288
|
+
settings.max_workers = mw
|
|
289
|
+
|
|
290
|
+
return settings
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def save_project_settings(project_path: Path, settings: ProjectSettings) -> None:
|
|
294
|
+
"""Save project settings to settings.yaml in the project directory."""
|
|
295
|
+
settings_file = project_path / SETTINGS_FILENAME
|
|
296
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
data = asdict(settings)
|
|
299
|
+
with open(settings_file, "w", encoding="utf-8") as f:
|
|
300
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_global_settings(projects_dir: Path) -> GlobalSettings:
|
|
304
|
+
"""Load global settings from projects/projects.yaml.
|
|
305
|
+
|
|
306
|
+
Returns defaults if the file does not exist or cannot be parsed.
|
|
307
|
+
"""
|
|
308
|
+
settings_file = projects_dir / GLOBAL_SETTINGS_FILENAME
|
|
309
|
+
if not settings_file.exists():
|
|
310
|
+
return GlobalSettings()
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
with open(settings_file, "r", encoding="utf-8") as f:
|
|
314
|
+
data = yaml.safe_load(f)
|
|
315
|
+
except (yaml.YAMLError, OSError):
|
|
316
|
+
return GlobalSettings()
|
|
317
|
+
|
|
318
|
+
if not isinstance(data, dict):
|
|
319
|
+
return GlobalSettings()
|
|
320
|
+
|
|
321
|
+
theme = data.get("theme", "dark")
|
|
322
|
+
if theme not in ("dark", "light", "os"):
|
|
323
|
+
theme = "dark"
|
|
324
|
+
|
|
325
|
+
limits_data = data.get("execution_limits")
|
|
326
|
+
if isinstance(limits_data, dict):
|
|
327
|
+
execution_limits = ExecutionLimits(
|
|
328
|
+
max_cores_per_job=limits_data.get("max_cores_per_job", 1),
|
|
329
|
+
memory_cap_per_job_gb=limits_data.get("memory_cap_per_job_gb", 0.0),
|
|
330
|
+
system_reserve_gb=limits_data.get("system_reserve_gb", 4.0),
|
|
331
|
+
swap_allowance_gb=limits_data.get("swap_allowance_gb", 0.0),
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
execution_limits = ExecutionLimits()
|
|
335
|
+
|
|
336
|
+
return GlobalSettings(
|
|
337
|
+
recent_project=data.get("recent_project"),
|
|
338
|
+
theme=theme,
|
|
339
|
+
exec_jobs_sash=data.get("exec_jobs_sash", 0),
|
|
340
|
+
exec_jobs_layout_cw=data.get("exec_jobs_layout_cw", 0),
|
|
341
|
+
exec_jobs_geometry=_as_geometry_map(data.get("exec_jobs_geometry")),
|
|
342
|
+
main_window_geometry=_as_geometry_map(data.get("main_window_geometry")),
|
|
343
|
+
main_window_layout_cw=data.get("main_window_layout_cw", 0),
|
|
344
|
+
max_workers=data.get("max_workers", 0),
|
|
345
|
+
execution_limits=execution_limits,
|
|
346
|
+
font_size_pt=data.get("font_size_pt", 10),
|
|
347
|
+
code_font_size_pt=data.get("code_font_size_pt", 0),
|
|
348
|
+
check_updates_on_startup=data.get("check_updates_on_startup", True),
|
|
349
|
+
polars_check_fingerprint=data.get("polars_check_fingerprint", ""),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def save_global_settings(projects_dir: Path, settings: GlobalSettings) -> None:
|
|
354
|
+
"""Save global settings to projects/projects.yaml."""
|
|
355
|
+
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
settings_file = projects_dir / GLOBAL_SETTINGS_FILENAME
|
|
357
|
+
|
|
358
|
+
data = asdict(settings)
|
|
359
|
+
with open(settings_file, "w", encoding="utf-8") as f:
|
|
360
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Read-only helper: enumerate a scenario's solves and flag investment solves.
|
|
2
|
+
|
|
3
|
+
The Calibrate-investments GUI needs, for a given input DB + scenario, the
|
|
4
|
+
ordered list of solves that scenario runs and — for each — whether it is an
|
|
5
|
+
*investment solve*. A solve is an investment solve when its ``invest_periods``
|
|
6
|
+
Array parameter is present and non-empty ("Array of periods where investments
|
|
7
|
+
are allowed."); an empty or absent ``invest_periods`` marks a dispatch-style
|
|
8
|
+
solve.
|
|
9
|
+
|
|
10
|
+
The scenario's solve list is read from ``model.solves`` (schema: "Sequence of
|
|
11
|
+
solves in the model. Array."), the authoritative ordered list of solves a model
|
|
12
|
+
runs, resolved under the scenario's alternative stack. It is intersected with
|
|
13
|
+
the ``solve`` entities that actually exist so a dangling name in ``model.solves``
|
|
14
|
+
cannot fabricate a checklist row. This mirrors ``SolveConfig.load_from_db``:
|
|
15
|
+
when no ``model.solves`` is defined and exactly one ``solve`` exists, that solve
|
|
16
|
+
is auto-wired as the model's single solve.
|
|
17
|
+
|
|
18
|
+
The DB is opened scenario-filtered and never written to.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
import spinedb_api as api
|
|
26
|
+
from spinedb_api import DatabaseMapping
|
|
27
|
+
|
|
28
|
+
from flextool.engine_polars._db_reader import (
|
|
29
|
+
DictMode,
|
|
30
|
+
get_single_entities,
|
|
31
|
+
params_to_dict,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SolveInfo:
|
|
37
|
+
"""One solve referenced by a scenario.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
name: The solve entity name.
|
|
41
|
+
has_invest_periods: ``True`` when the solve's ``invest_periods`` Array
|
|
42
|
+
is present and non-empty (an investment solve); ``False`` for a
|
|
43
|
+
dispatch-style solve with an empty or absent ``invest_periods``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
has_invest_periods: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_scenario_solves(db_url: str, scenario_name: str) -> list[SolveInfo]:
|
|
51
|
+
"""Return the solves *scenario_name* runs, flagged by investment status.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
db_url: URL of a FlexTool input database (e.g. ``sqlite:///path.sqlite``).
|
|
55
|
+
scenario_name: Name of the scenario to resolve.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
One :class:`SolveInfo` per solve the scenario runs, in the order the
|
|
59
|
+
scenario's ``model.solves`` lists them (dangling names dropped).
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If *scenario_name* is not a scenario in the database.
|
|
63
|
+
"""
|
|
64
|
+
scen_config = api.filters.scenario_filter.scenario_filter_config(scenario_name)
|
|
65
|
+
with DatabaseMapping(db_url) as db:
|
|
66
|
+
# Existence check before filtering: an unknown scenario would otherwise
|
|
67
|
+
# silently resolve to an empty stack rather than a loud error.
|
|
68
|
+
scenario_names = {s["name"] for s in db.find_scenarios()}
|
|
69
|
+
if scenario_name not in scenario_names:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"No scenario named {scenario_name!r} in the database. "
|
|
72
|
+
f"Available scenarios: {sorted(scenario_names)}."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
|
|
76
|
+
db.fetch_all("parameter_value")
|
|
77
|
+
|
|
78
|
+
# Authoritative ordered solve list per model (Array → list of names).
|
|
79
|
+
model_solves: dict = params_to_dict(
|
|
80
|
+
db=db, cl="model", par="solves", mode=DictMode.DEFAULTDICT
|
|
81
|
+
)
|
|
82
|
+
existing_solves = get_single_entities(db=db, entity_class_name="solve")
|
|
83
|
+
|
|
84
|
+
# Auto-wire the single-solve case exactly as SolveConfig.load_from_db.
|
|
85
|
+
if len(model_solves) == 0 and len(existing_solves) == 1:
|
|
86
|
+
model_solves = {"flextool": [existing_solves[0]]}
|
|
87
|
+
|
|
88
|
+
# invest_periods Array per solve (Array → list of period names).
|
|
89
|
+
invest_periods: dict = params_to_dict(
|
|
90
|
+
db=db, cl="solve", par="invest_periods", mode=DictMode.DEFAULTDICT
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
existing = set(existing_solves)
|
|
94
|
+
result: list[SolveInfo] = []
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
for solve_names in model_solves.values():
|
|
97
|
+
for solve_name in solve_names:
|
|
98
|
+
if solve_name in seen or solve_name not in existing:
|
|
99
|
+
continue
|
|
100
|
+
seen.add(solve_name)
|
|
101
|
+
has_invest = bool(invest_periods.get(solve_name))
|
|
102
|
+
result.append(SolveInfo(name=solve_name, has_invest_periods=has_invest))
|
|
103
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Mouse drag-and-drop reordering for ttk.Treeview rows."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from tkinter import ttk
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DragReorderController:
|
|
10
|
+
"""Adds drag-to-reorder behaviour to a ttk.Treeview.
|
|
11
|
+
|
|
12
|
+
Engages only on a ButtonPress-1 inside a row's BODY (cell region),
|
|
13
|
+
so it composes with the CheckTreeController whose click handler
|
|
14
|
+
captures clicks in the check column. The drag is ignored unless the
|
|
15
|
+
pointer actually moves (so a plain click still selects the row via
|
|
16
|
+
default Treeview behaviour).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_DRAG_THRESHOLD_PX = 4
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
tree: ttk.Treeview,
|
|
24
|
+
*,
|
|
25
|
+
check_column: str = "check",
|
|
26
|
+
on_reorder: Callable[[list[str]], None] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._tree = tree
|
|
29
|
+
self._check_column = check_column
|
|
30
|
+
self._on_reorder = on_reorder
|
|
31
|
+
self._press_x: int | None = None
|
|
32
|
+
self._press_y: int | None = None
|
|
33
|
+
self._press_iid: str | None = None
|
|
34
|
+
self._dragging: bool = False
|
|
35
|
+
|
|
36
|
+
tree.bind("<ButtonPress-1>", self._on_press, add="+")
|
|
37
|
+
tree.bind("<B1-Motion>", self._on_motion, add="+")
|
|
38
|
+
tree.bind("<ButtonRelease-1>", self._on_release, add="+")
|
|
39
|
+
|
|
40
|
+
def _click_in_check_column(self, event: tk.Event) -> bool: # type: ignore[type-arg]
|
|
41
|
+
if self._tree.identify("region", event.x, event.y) != "cell":
|
|
42
|
+
return False
|
|
43
|
+
column = self._tree.identify_column(event.x)
|
|
44
|
+
try:
|
|
45
|
+
idx = int(column.lstrip("#")) - 1
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
cols = self._tree["columns"]
|
|
49
|
+
return 0 <= idx < len(cols) and cols[idx] == self._check_column
|
|
50
|
+
|
|
51
|
+
def _on_press(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
52
|
+
if self._click_in_check_column(event):
|
|
53
|
+
return
|
|
54
|
+
iid = self._tree.identify_row(event.y)
|
|
55
|
+
if not iid:
|
|
56
|
+
return
|
|
57
|
+
self._press_x = event.x
|
|
58
|
+
self._press_y = event.y
|
|
59
|
+
self._press_iid = iid
|
|
60
|
+
self._dragging = False
|
|
61
|
+
|
|
62
|
+
def _on_motion(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
63
|
+
if self._press_iid is None or self._press_x is None or self._press_y is None:
|
|
64
|
+
return
|
|
65
|
+
if not self._dragging:
|
|
66
|
+
if abs(event.x - self._press_x) < self._DRAG_THRESHOLD_PX and \
|
|
67
|
+
abs(event.y - self._press_y) < self._DRAG_THRESHOLD_PX:
|
|
68
|
+
return
|
|
69
|
+
self._dragging = True
|
|
70
|
+
self._tree.config(cursor="fleur")
|
|
71
|
+
target = self._tree.identify_row(event.y)
|
|
72
|
+
if not target or target == self._press_iid:
|
|
73
|
+
return
|
|
74
|
+
# Move the source row to where the target row currently sits.
|
|
75
|
+
try:
|
|
76
|
+
target_index = self._tree.index(target)
|
|
77
|
+
self._tree.move(self._press_iid, "", target_index)
|
|
78
|
+
except tk.TclError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def _on_release(self, event: tk.Event) -> None: # type: ignore[type-arg]
|
|
82
|
+
was_dragging = self._dragging
|
|
83
|
+
self._press_x = self._press_y = None
|
|
84
|
+
self._press_iid = None
|
|
85
|
+
self._dragging = False
|
|
86
|
+
self._tree.config(cursor="")
|
|
87
|
+
if was_dragging and self._on_reorder is not None:
|
|
88
|
+
self._on_reorder(list(self._tree.get_children()))
|