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,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from flextool.plot_outputs.config import flatten_new_format
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_plot_configs(yaml_path: Path) -> list[str]:
|
|
14
|
+
"""Read a plot config YAML file and return available config names.
|
|
15
|
+
|
|
16
|
+
Supports both old format (result_key at top level) and new format
|
|
17
|
+
(entry-name grouping with ``group``/``order`` keys). New-format
|
|
18
|
+
entries are flattened to result_key level before scanning.
|
|
19
|
+
|
|
20
|
+
Returns a sorted list of config names
|
|
21
|
+
(e.g. ``['chunks', 'default', 'reserve']``).
|
|
22
|
+
"""
|
|
23
|
+
if not yaml_path.is_file():
|
|
24
|
+
logger.warning("Plot config file not found: %s", yaml_path)
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
29
|
+
data = yaml.safe_load(f)
|
|
30
|
+
except (yaml.YAMLError, OSError) as exc:
|
|
31
|
+
logger.error("Failed to read plot config %s: %s", yaml_path, exc)
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
plots = data.get("plots")
|
|
38
|
+
if not isinstance(plots, dict):
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
# Flatten new-format entries so we always iterate flat result_keys
|
|
42
|
+
plots = flatten_new_format(plots)
|
|
43
|
+
|
|
44
|
+
config_names: set[str] = set()
|
|
45
|
+
has_default = False
|
|
46
|
+
|
|
47
|
+
for _result_key, result_value in plots.items():
|
|
48
|
+
if not isinstance(result_value, dict):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# Look at the first sub-key to decide whether this entry is a
|
|
52
|
+
# bare plot definition (belongs to ``default``) or a mapping of
|
|
53
|
+
# config-name -> plot definition.
|
|
54
|
+
first_subkey = next(iter(result_value), None)
|
|
55
|
+
if first_subkey == "plot_name":
|
|
56
|
+
# Direct plot definition -> part of the implicit "default" config
|
|
57
|
+
has_default = True
|
|
58
|
+
else:
|
|
59
|
+
# Each sub-key is a config name (e.g. "default", "chunks",
|
|
60
|
+
# "debug", "reserve", ...).
|
|
61
|
+
for config_name in result_value:
|
|
62
|
+
config_names.add(config_name)
|
|
63
|
+
|
|
64
|
+
# If any entries had a bare plot_name, they belong to "default".
|
|
65
|
+
if has_default:
|
|
66
|
+
config_names.add("default")
|
|
67
|
+
|
|
68
|
+
return sorted(config_names)
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PlotSettings:
|
|
8
|
+
"""Settings for a set of plots (single scenario or comparison)."""
|
|
9
|
+
start_time: int = 0
|
|
10
|
+
duration: int = 0
|
|
11
|
+
config_file: str = ""
|
|
12
|
+
active_configs: list[str] = field(default_factory=list)
|
|
13
|
+
dispatch_plots: bool = True # comparison only: include --dispatch-plots
|
|
14
|
+
only_first_file: bool = False # limit to one file per plot (quick overview)
|
|
15
|
+
# Per-variant duration, keyed by variant letter (e.g. {"h": 168, "w": 8760}).
|
|
16
|
+
# Only ints — "all" sentinel from the template is resolved to a concrete
|
|
17
|
+
# integer the first time the variant is seen and stored here.
|
|
18
|
+
variant_durations: dict[str, int] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ViewerSettings:
|
|
23
|
+
"""Settings for the result viewer window."""
|
|
24
|
+
last_scenario: str = ""
|
|
25
|
+
last_entry: str = "" # e.g., "0.0"
|
|
26
|
+
last_variant: str = "" # e.g., "t"
|
|
27
|
+
last_mode: str = "single" # "single", "comparison", "network"
|
|
28
|
+
# Saved viewer-window placement, keyed by monitor configuration
|
|
29
|
+
# (ui_metrics.monitor_signature): {signature -> "WxH+X+Y"}.
|
|
30
|
+
window_geometry: dict[str, str] = field(default_factory=dict)
|
|
31
|
+
left_pane_width: int = 0 # saved horizontal sash position (0 = use default)
|
|
32
|
+
scenario_pane_height: int = 0 # saved vertical sash in left column (0 = default)
|
|
33
|
+
# cw (TkDefaultFont.measure("0")) at the time window_geometry / sash
|
|
34
|
+
# positions were saved. 0 means unknown — use raw saved values.
|
|
35
|
+
layout_cw: int = 0
|
|
36
|
+
cache_gb: float = 0.5 # plot cache memory limit in GB
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ScenarioRun:
|
|
41
|
+
"""Recorded resource usage from a previous successful scenario run.
|
|
42
|
+
|
|
43
|
+
Persisted in settings.yaml under ``scenario_resource_history``, keyed
|
|
44
|
+
by output subdir. Used by the execution manager to set a learned
|
|
45
|
+
memory budget for the next run of the same scenario.
|
|
46
|
+
"""
|
|
47
|
+
peak_rss_mb: float = 0.0 # high-water mark observed by MemoryWatchdog
|
|
48
|
+
runtime_s: float = 0.0 # wall-clock seconds from start_time to end_time
|
|
49
|
+
last_run: str = "" # ISO-8601 local timestamp, e.g. "2026-04-25T14:30:15"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ProjectSettings:
|
|
54
|
+
"""Per-project settings stored in settings.yaml."""
|
|
55
|
+
# Auto-generate flags
|
|
56
|
+
auto_generate_scen_plots: bool = True
|
|
57
|
+
auto_generate_scen_excels: bool = False
|
|
58
|
+
auto_generate_scen_csvs: bool = True
|
|
59
|
+
auto_generate_comp_plots: bool = True
|
|
60
|
+
auto_generate_comp_excel: bool = False
|
|
61
|
+
# SpineDB results database. Produced only during the solve (the writer
|
|
62
|
+
# needs the live s/par namespaces), so it has no parquet-based regen
|
|
63
|
+
# path — unlike the other outputs above. One results.sqlite per project,
|
|
64
|
+
# each scenario appended as its own alternative.
|
|
65
|
+
auto_generate_comp_spinedb: bool = False
|
|
66
|
+
|
|
67
|
+
# Diagnostic verbosity for scenario execution. Controlled by the
|
|
68
|
+
# "Debug" radio group in the main window. Values mirror the CLI
|
|
69
|
+
# ``--debug`` flag in ``flextool/cli/cmd_run_flextool.py``:
|
|
70
|
+
#
|
|
71
|
+
# "off" — no extra flags appended.
|
|
72
|
+
# "basic" — appends ``--debug=basic`` (verbose memory checkpoints
|
|
73
|
+
# + DEBUG log level; no tracemalloc overhead).
|
|
74
|
+
# "full" — appends ``--debug=full --csv-dump`` (basic + tracemalloc
|
|
75
|
+
# diagnostics CSV + retained intermediate CSVs). Slow;
|
|
76
|
+
# reserved for allocation-regression investigations.
|
|
77
|
+
debug_level: str = "off"
|
|
78
|
+
|
|
79
|
+
# When True, scenario execution runs with --save-memory: builds the
|
|
80
|
+
# LP, writes MPS, drops everything Python-side, then spawns a HiGHS
|
|
81
|
+
# subprocess to solve. Parent and solver memory no longer compound
|
|
82
|
+
# in the same process address space. Trades ~+30-60 s I/O per
|
|
83
|
+
# sub-solve and warm-LP reuse for substantial peak RSS relief.
|
|
84
|
+
# Controlled by the "Save memory" checkbox in the main window,
|
|
85
|
+
# above "Debug".
|
|
86
|
+
save_memory: bool = False
|
|
87
|
+
|
|
88
|
+
# ── Solver options (CLI knobs surfaced via the "Solver options…"
|
|
89
|
+
# dialog launched from the main window side menu). Values mirror
|
|
90
|
+
# the matching flags in ``flextool/cli/cmd_run_flextool.py`` and
|
|
91
|
+
# are appended by ExecutionManager only when they differ from the
|
|
92
|
+
# defaults below, so the engine command line stays clean on the
|
|
93
|
+
# common path.
|
|
94
|
+
#
|
|
95
|
+
# The previous ``highs_threads`` and ``user_bound_scale`` fields
|
|
96
|
+
# were dropped: HiGHS thread count is the canonical responsibility
|
|
97
|
+
# of ``execution_limits.max_cores_per_job`` (Execution jobs
|
|
98
|
+
# window), and ``user_bound_scale`` overlaps with ``scaling`` for
|
|
99
|
+
# the rare advanced-user case where the autoscaler is bypassed in
|
|
100
|
+
# favour of a hand-set bound scale via ``solver_arguments``.
|
|
101
|
+
|
|
102
|
+
# HiGHS log verbosity (``--solver-log-level``). One of
|
|
103
|
+
# ``"silent"`` | ``"normal"`` | ``"verbose"``. Default "normal";
|
|
104
|
+
# only appended when non-default.
|
|
105
|
+
solver_log_level: str = "normal"
|
|
106
|
+
|
|
107
|
+
# HiGHS wall-clock time limit in whole seconds
|
|
108
|
+
# (``--solver-time-limit``). 0 means "no limit" (the CLI's unset
|
|
109
|
+
# default). Only appended when > 0.
|
|
110
|
+
solver_time_limit: int = 0
|
|
111
|
+
|
|
112
|
+
# HiGHS MIP relative optimality gap (``--solver-mip-gap``), routed to
|
|
113
|
+
# HiGHS' ``mip_rel_gap`` option. ``solver_mip_gap_set`` gates whether
|
|
114
|
+
# the value is sent at all: when True the gap is appended (and 0 is a
|
|
115
|
+
# valid value — solve to a proven exact optimum); when False no
|
|
116
|
+
# override is emitted and the solver_config/<solver>.opt baseline (or
|
|
117
|
+
# the solver's built-in default) governs. Only affects MIP solves
|
|
118
|
+
# (integer investments, unit-commitment / online variables); pure-LP
|
|
119
|
+
# solves ignore it.
|
|
120
|
+
solver_mip_gap_set: bool = True
|
|
121
|
+
solver_mip_gap: float = 0.001
|
|
122
|
+
|
|
123
|
+
# On-disk format used when the solver is dispatched via a matrix
|
|
124
|
+
# file (``--matrix-file-format``). One of ``"mps"`` | ``"lp"``.
|
|
125
|
+
# Default "mps"; only appended when non-default. The in-process
|
|
126
|
+
# vs. file decision itself is implicit (HiGHS + no --save-memory
|
|
127
|
+
# = direct binding; commercial solvers + HiGHS --save-memory =
|
|
128
|
+
# file write using the chosen format).
|
|
129
|
+
matrix_file_format: str = "mps"
|
|
130
|
+
|
|
131
|
+
# FlexTool autoscaler strategy (``--scaling``). One of
|
|
132
|
+
# ``"off"`` | ``"solver_only"`` | ``"basic"`` | ``"full"``.
|
|
133
|
+
# Default "full"; only appended when non-default.
|
|
134
|
+
scaling: str = "full"
|
|
135
|
+
|
|
136
|
+
# HiGHS ``presolve`` override (``--presolve``). One of
|
|
137
|
+
# ``"on"`` | ``"off"`` | ``"choose"``. All three are appended to
|
|
138
|
+
# the engine command line: "choose" is HiGHS' native default and
|
|
139
|
+
# lets the solver decide per-problem, overriding the engine's
|
|
140
|
+
# determinism-pinned "on" baseline (that pin only governs the test
|
|
141
|
+
# gate, which does not go through this CLI path). Default "choose".
|
|
142
|
+
presolve: str = "choose"
|
|
143
|
+
|
|
144
|
+
# ── Calibrate-investments dialog settings (surfaced via the
|
|
145
|
+
# "Calibrate investments…" dialog). Representative-period build knobs
|
|
146
|
+
# (calib_rp_*), the set of solves the calibrator iterates over
|
|
147
|
+
# (calib_selected_solves), and the iteration / sizing / damping /
|
|
148
|
+
# stall-guard controls. Persisted so the dialog reopens with the
|
|
149
|
+
# user's last choices.
|
|
150
|
+
calib_rp_n_rp: int = 20
|
|
151
|
+
calib_rp_period_length: int = 108
|
|
152
|
+
calib_rp_force_sustained: bool = True
|
|
153
|
+
calib_rp_force_peak: bool = False
|
|
154
|
+
calib_rp_force_window: int = 24
|
|
155
|
+
# Disposition of the freshly built RP alternative:
|
|
156
|
+
# "detached" — create the alternative but attach it to nothing;
|
|
157
|
+
# "add" — append it to each selected scenario's stack (default,
|
|
158
|
+
# so its investment solve immediately uses the periods);
|
|
159
|
+
# "new_scenario" — leave the selected scenarios untouched and clone each
|
|
160
|
+
# into a new "<scenario>_<alternative>" scenario that
|
|
161
|
+
# carries the same stack plus the new alternative on top.
|
|
162
|
+
calib_rp_scenario_mode: str = "add" # "detached" | "add" | "new_scenario"
|
|
163
|
+
calib_selected_solves: list[str] = field(default_factory=list)
|
|
164
|
+
calib_max_iterations: int = 8
|
|
165
|
+
calib_sizing: str = "timed" # "timed" | "uniform"
|
|
166
|
+
calib_overshoot_pct: float = 0.0
|
|
167
|
+
calib_damping_first: float = 1.0
|
|
168
|
+
calib_damping_remaining: float = 0.5
|
|
169
|
+
calib_stall_fraction: float = 0.05
|
|
170
|
+
calib_keep_artifacts: bool = False
|
|
171
|
+
|
|
172
|
+
# Plot settings
|
|
173
|
+
single_plot_settings: PlotSettings = field(default_factory=PlotSettings)
|
|
174
|
+
comparison_plot_settings: PlotSettings = field(default_factory=PlotSettings)
|
|
175
|
+
|
|
176
|
+
# Input source numbers: source name -> number (live sources only).
|
|
177
|
+
input_source_numbers: dict[str, int] = field(default_factory=dict)
|
|
178
|
+
|
|
179
|
+
# Persistent per-number source identity: str(number) -> SourceRecord.
|
|
180
|
+
# Survives file deletion so orphaned results stay attributable and a
|
|
181
|
+
# re-added file reclaims its old number. Garbage-collected once a
|
|
182
|
+
# number has neither a live file nor any results. See SourceRecord.
|
|
183
|
+
source_registry: dict[str, SourceRecord] = field(default_factory=dict)
|
|
184
|
+
|
|
185
|
+
# External input references: source name -> POSIX path relative to project root.
|
|
186
|
+
# Files are read in place (not copied into input_sources/).
|
|
187
|
+
external_refs: dict[str, str] = field(default_factory=dict)
|
|
188
|
+
|
|
189
|
+
# Bare-name ownership for executed scenario folders.
|
|
190
|
+
# Maps scenario_name -> source_number. Scenarios in this map write to
|
|
191
|
+
# ``output_parquet/<name>/`` (no suffix); other sources with the same
|
|
192
|
+
# scenario name write to ``output_parquet/<name>_<src#>/``.
|
|
193
|
+
bare_output_owners: dict[str, int] = field(default_factory=dict)
|
|
194
|
+
|
|
195
|
+
# Ordered list of scenario names for execution
|
|
196
|
+
scenario_order: list[str] = field(default_factory=list)
|
|
197
|
+
|
|
198
|
+
# User-ordered list of executed-scenario names for the result viewer
|
|
199
|
+
# tree (drag/Alt+Up/Down reordering). New scenarios are appended at the
|
|
200
|
+
# end of this list when first seen. Names that no longer exist on disk
|
|
201
|
+
# are pruned at next scan.
|
|
202
|
+
executed_scenario_order: list[str] = field(default_factory=list)
|
|
203
|
+
|
|
204
|
+
# Scenarios used for the last comparison outputs
|
|
205
|
+
comp_plots_scenarios: list[str] = field(default_factory=list)
|
|
206
|
+
comp_excel_scenarios: list[str] = field(default_factory=list)
|
|
207
|
+
|
|
208
|
+
# Scenarios ticked in the result-viewer comparison mode. Distinct from
|
|
209
|
+
# comp_plots_scenarios (which tracks the last run) because the viewer's
|
|
210
|
+
# ticks may evolve independently until a regen/plot is triggered.
|
|
211
|
+
comp_viewer_scenarios: list[str] = field(default_factory=list)
|
|
212
|
+
|
|
213
|
+
# Persisted checkbox states
|
|
214
|
+
checked_input_sources: list[str] = field(default_factory=list) # source names
|
|
215
|
+
checked_available_scenarios: list[str] = field(default_factory=list) # "source_number|name" keys
|
|
216
|
+
checked_executed_scenarios: list[str] = field(default_factory=list) # scenario names
|
|
217
|
+
|
|
218
|
+
# Result viewer settings
|
|
219
|
+
viewer_settings: ViewerSettings = field(default_factory=ViewerSettings)
|
|
220
|
+
|
|
221
|
+
# Per-scenario resource history: output_subdir -> ScenarioRun.
|
|
222
|
+
# Populated after a successful run; consulted at dispatch time so the
|
|
223
|
+
# next run gets a learned memory budget instead of the static auto fallback.
|
|
224
|
+
scenario_resource_history: dict[str, ScenarioRun] = field(default_factory=dict)
|
|
225
|
+
|
|
226
|
+
# Per-project execution limits — primary source of truth for the
|
|
227
|
+
# execution manager. Defaults match the conservative single-job
|
|
228
|
+
# profile the user prefers as a baseline (1 worker, 1 core, auto
|
|
229
|
+
# budget, 0.5 GB reserve, no swap). GlobalSettings still carries
|
|
230
|
+
# the same fields as a legacy fallback; see ExecutionManager.
|
|
231
|
+
execution_limits: "ExecutionLimits" = field(
|
|
232
|
+
default_factory=lambda: ExecutionLimits()
|
|
233
|
+
)
|
|
234
|
+
# Last chosen "Max. parallel executions" for this project. 0 means
|
|
235
|
+
# "use the GlobalSettings fallback"; otherwise an explicit value
|
|
236
|
+
# honoured by ExecutionManager.
|
|
237
|
+
max_workers: int = 1
|
|
238
|
+
|
|
239
|
+
# Transient flag (not persisted): set by execution manager when scenarios
|
|
240
|
+
# finish, cleared by the result viewer when it picks up the changes.
|
|
241
|
+
scenarios_changed: bool = field(default=False, repr=False)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class ExecutionLimits:
|
|
246
|
+
"""Per-machine resource limits for FlexTool subprocess execution.
|
|
247
|
+
|
|
248
|
+
Primary storage is ``ProjectSettings.execution_limits`` (per-project
|
|
249
|
+
settings.yaml). ``GlobalSettings.execution_limits`` is retained as a
|
|
250
|
+
legacy fallback for projects whose settings.yaml predates the
|
|
251
|
+
per-project field. A value of 0 / 0.0 means "auto" (compute at
|
|
252
|
+
dispatch time from system info).
|
|
253
|
+
"""
|
|
254
|
+
max_cores_per_job: int = 1 # passed as --highs-threads to each subprocess
|
|
255
|
+
memory_cap_per_job_gb: float = 0.0 # 0 = auto: (system_total - system_reserve_gb) / max_workers
|
|
256
|
+
system_reserve_gb: float = 0.5 # tier 4: leave at least this much free system RAM
|
|
257
|
+
swap_allowance_gb: float = 0.0 # tier 4: 0 = no swap; >0 allowed but warns user
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class GlobalSettings:
|
|
262
|
+
"""Global settings stored in projects/projects.yaml."""
|
|
263
|
+
recent_project: str | None = None
|
|
264
|
+
theme: str = "dark" # Valid values: "dark", "light", "os"
|
|
265
|
+
exec_jobs_sash: int = 0 # saved Jobs/Progress sash position (0 = default)
|
|
266
|
+
# cw at the time the exec-jobs geometry/sash was saved. 0 = unknown.
|
|
267
|
+
exec_jobs_layout_cw: int = 0
|
|
268
|
+
# Saved exec-jobs window placement, keyed by monitor configuration
|
|
269
|
+
# (ui_metrics.monitor_signature): {signature -> "WxH+X+Y"}. Each layout
|
|
270
|
+
# remembers its own placement; an unseen layout falls back to the
|
|
271
|
+
# monitor-aware default. Restored clamped/rescaled on open.
|
|
272
|
+
exec_jobs_geometry: dict[str, str] = field(default_factory=dict)
|
|
273
|
+
# Saved main-window placement, same per-configuration scheme as above.
|
|
274
|
+
main_window_geometry: dict[str, str] = field(default_factory=dict)
|
|
275
|
+
# cw at the time main_window_geometry was saved. 0 = unknown.
|
|
276
|
+
main_window_layout_cw: int = 0
|
|
277
|
+
# Legacy fallback for projects whose settings.yaml predates the
|
|
278
|
+
# per-project ``max_workers`` field. 0 means "not set yet".
|
|
279
|
+
max_workers: int = 0
|
|
280
|
+
# Legacy fallback for projects whose settings.yaml predates the
|
|
281
|
+
# per-project ``execution_limits`` field. ExecutionManager reads
|
|
282
|
+
# ProjectSettings first and only consults this when the project
|
|
283
|
+
# field is missing / empty.
|
|
284
|
+
execution_limits: ExecutionLimits = field(default_factory=ExecutionLimits)
|
|
285
|
+
font_size_pt: int = 10 # body / menu / heading base size
|
|
286
|
+
code_font_size_pt: int = 0 # TkFixedFont (logs, code views); 0 = auto = body
|
|
287
|
+
# Check PyPI / the git remote for a newer version once at startup.
|
|
288
|
+
check_updates_on_startup: bool = True
|
|
289
|
+
# env_fingerprint() of the environment for which the polars native
|
|
290
|
+
# self-check last passed. Empty / mismatched => the check re-runs once
|
|
291
|
+
# at startup (so a fresh install or a swapped polars build is verified
|
|
292
|
+
# exactly once). See flextool.env_check.
|
|
293
|
+
polars_check_fingerprint: str = ""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class SourceRecord:
|
|
298
|
+
"""Persistent identity of an input source, keyed by its source number.
|
|
299
|
+
|
|
300
|
+
Stored in ``ProjectSettings.source_registry`` as ``str(number) ->
|
|
301
|
+
SourceRecord``. Unlike ``input_source_numbers`` (a live name→number
|
|
302
|
+
index), this record **survives deletion of the underlying file** so
|
|
303
|
+
that:
|
|
304
|
+
|
|
305
|
+
* executed-scenario folders left on disk (``output_parquet/<name>_N``)
|
|
306
|
+
can still be attributed to a named file (drives the greyed-out
|
|
307
|
+
"ghost" rows in the input-source list), and
|
|
308
|
+
* re-adding the same file reclaims its original number instead of
|
|
309
|
+
drifting to a new one (the renumbering bug).
|
|
310
|
+
|
|
311
|
+
A record is garbage-collected from settings.yaml on the next
|
|
312
|
+
``refresh()`` once **neither** a live file **nor** any executed-
|
|
313
|
+
scenario results reference its number — so empty ghosts never
|
|
314
|
+
accumulate. The "retired" state is derived per refresh from
|
|
315
|
+
``(has live file?, has results?)``; it is not persisted here.
|
|
316
|
+
"""
|
|
317
|
+
name: str = ""
|
|
318
|
+
# Project-root-relative POSIX path (e.g. "input_sources/examples.sqlite"
|
|
319
|
+
# for an internal file, "../data/input.xlsx" for an external one).
|
|
320
|
+
# Empty when the path is unknown (legacy orphan with no recorded owner).
|
|
321
|
+
path: str = ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class InputSourceInfo:
|
|
326
|
+
"""Information about an input source file."""
|
|
327
|
+
name: str
|
|
328
|
+
file_type: str # "xlsx" or "sqlite"
|
|
329
|
+
number: int
|
|
330
|
+
status: str # "ok", "error", "empty", "editing", or "retired"
|
|
331
|
+
scenarios: list[str] = field(default_factory=list)
|
|
332
|
+
# Set when the source lives outside the project; stored as POSIX path
|
|
333
|
+
# relative to the project root (e.g. "../data/input.xlsx").
|
|
334
|
+
external_rel_path: str | None = None
|
|
335
|
+
# True for a "ghost" row: the input file is gone but executed-scenario
|
|
336
|
+
# results still reference this number. Such rows are informational
|
|
337
|
+
# (greyed out, not runnable) and disappear once the results are removed.
|
|
338
|
+
retired: bool = False
|
|
339
|
+
# Number of executed-scenario result folders that resolve to this
|
|
340
|
+
# source number (used for the ghost-row tooltip / GC decision).
|
|
341
|
+
result_count: int = 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class ScenarioInfo:
|
|
346
|
+
"""Information about an available scenario."""
|
|
347
|
+
name: str
|
|
348
|
+
source_number: int
|
|
349
|
+
source_name: str
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class ExecutedScenarioInfo:
|
|
354
|
+
"""Information about an executed scenario with results."""
|
|
355
|
+
name: str
|
|
356
|
+
source_number: int
|
|
357
|
+
timestamp: str
|
|
358
|
+
has_plots: bool = False
|
|
359
|
+
has_excel: bool = False
|
|
360
|
+
has_csvs: bool = False
|
|
361
|
+
has_comp_plots: bool = False
|
|
362
|
+
has_comp_excel: bool = False
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Manages Spine DB Editor instances and detects potential uncommitted changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DbEditorManager:
|
|
16
|
+
"""Manages communication with Spine DB Editor instances.
|
|
17
|
+
|
|
18
|
+
Tracks launched editor processes so the GUI can:
|
|
19
|
+
- Warn when a user tries to execute scenarios whose source database
|
|
20
|
+
is still open in an editor.
|
|
21
|
+
- Open additional databases by launching new editor instances.
|
|
22
|
+
|
|
23
|
+
Spine DB Editor (``spine-db-editor``) accepts multiple database URLs
|
|
24
|
+
on the command line and opens them as tabs (use ``-s`` for separate
|
|
25
|
+
tabs). However, there is no IPC mechanism to add a tab to an
|
|
26
|
+
already-running instance, so each call to :meth:`open_database`
|
|
27
|
+
launches a new process.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
# source_name -> list of Popen objects (a source can be opened multiple times)
|
|
32
|
+
self._processes: dict[str, list[subprocess.Popen]] = {}
|
|
33
|
+
# Captured stdout/stderr per launched process, so an immediate crash
|
|
34
|
+
# (e.g. an incomplete Spine Toolbox install) can be shown in the GUI
|
|
35
|
+
# instead of only printing a traceback to the launching terminal.
|
|
36
|
+
self._launch_logs: dict[subprocess.Popen, Path] = {}
|
|
37
|
+
|
|
38
|
+
# ── Opening databases ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def open_database(self, db_url: str, source_name: str) -> subprocess.Popen | None:
|
|
41
|
+
"""Launch ``spine-db-editor`` for *db_url* and track the process.
|
|
42
|
+
|
|
43
|
+
The editor's own stdout/stderr is redirected to a temporary log file
|
|
44
|
+
so that, if it exits immediately, the caller can read the failure via
|
|
45
|
+
:meth:`read_launch_output` and surface it to the user.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
db_url: SQLAlchemy-style database URL (e.g. ``sqlite:///path``).
|
|
49
|
+
source_name: Logical name used to track which input source is
|
|
50
|
+
being edited.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The :class:`subprocess.Popen` object, or ``None`` if the
|
|
54
|
+
editor executable is not found on ``PATH``.
|
|
55
|
+
"""
|
|
56
|
+
exe = shutil.which("spine-db-editor")
|
|
57
|
+
if exe is None:
|
|
58
|
+
logger.warning("spine-db-editor not found on PATH")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
log_path: Path | None = None
|
|
62
|
+
log_handle = None
|
|
63
|
+
try:
|
|
64
|
+
fd, name = tempfile.mkstemp(prefix="flextool_db_editor_", suffix=".log")
|
|
65
|
+
log_path = Path(name)
|
|
66
|
+
log_handle = os.fdopen(fd, "wb")
|
|
67
|
+
except OSError:
|
|
68
|
+
logger.debug("Could not create db-editor launch log", exc_info=True)
|
|
69
|
+
log_path = None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if log_handle is not None:
|
|
73
|
+
proc = subprocess.Popen(
|
|
74
|
+
[exe, db_url], stdout=log_handle, stderr=subprocess.STDOUT
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
proc = subprocess.Popen([exe, db_url])
|
|
78
|
+
except OSError:
|
|
79
|
+
logger.warning("Failed to launch spine-db-editor", exc_info=True)
|
|
80
|
+
if log_path is not None:
|
|
81
|
+
try:
|
|
82
|
+
log_path.unlink(missing_ok=True)
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
return None
|
|
86
|
+
finally:
|
|
87
|
+
# The child inherits its own dup of the fd; close the parent copy.
|
|
88
|
+
if log_handle is not None:
|
|
89
|
+
log_handle.close()
|
|
90
|
+
|
|
91
|
+
if log_path is not None:
|
|
92
|
+
self._launch_logs[proc] = log_path
|
|
93
|
+
self._processes.setdefault(source_name, []).append(proc)
|
|
94
|
+
self._reap_dead(source_name)
|
|
95
|
+
return proc
|
|
96
|
+
|
|
97
|
+
def read_launch_output(self, proc: subprocess.Popen) -> str:
|
|
98
|
+
"""Return (and consume) the captured launch output for *proc*.
|
|
99
|
+
|
|
100
|
+
Used by the GUI when an editor process died right after launch, to
|
|
101
|
+
show the user why. The temporary log is removed once read.
|
|
102
|
+
"""
|
|
103
|
+
log_path = self._launch_logs.pop(proc, None)
|
|
104
|
+
if log_path is None:
|
|
105
|
+
return ""
|
|
106
|
+
text = ""
|
|
107
|
+
try:
|
|
108
|
+
if log_path.exists():
|
|
109
|
+
text = log_path.read_text(errors="replace")
|
|
110
|
+
except OSError:
|
|
111
|
+
logger.debug("Could not read db-editor launch log", exc_info=True)
|
|
112
|
+
finally:
|
|
113
|
+
try:
|
|
114
|
+
log_path.unlink(missing_ok=True)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return text
|
|
118
|
+
|
|
119
|
+
# ── Status queries ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def is_editor_running(self, source_name: str) -> bool:
|
|
122
|
+
"""Return ``True`` if at least one editor process is alive for *source_name*."""
|
|
123
|
+
self._reap_dead(source_name)
|
|
124
|
+
procs = self._processes.get(source_name, [])
|
|
125
|
+
return any(p.poll() is None for p in procs)
|
|
126
|
+
|
|
127
|
+
def has_uncommitted_changes(self, db_path: Path) -> bool:
|
|
128
|
+
"""Heuristic check for potential uncommitted changes.
|
|
129
|
+
|
|
130
|
+
The Spine DB Editor keeps uncommitted edits in an in-memory
|
|
131
|
+
SQLAlchemy session, so there is **no reliable** way to detect
|
|
132
|
+
them from outside the process. This method uses two heuristics:
|
|
133
|
+
|
|
134
|
+
1. **Process tracking** -- if we launched an editor for the
|
|
135
|
+
database and it is still running, we conservatively assume
|
|
136
|
+
there *may* be uncommitted changes.
|
|
137
|
+
2. **SQLite artefact files** -- the presence of a non-empty
|
|
138
|
+
``-journal`` or ``-wal`` file alongside the database can
|
|
139
|
+
indicate an open write transaction (though these can also
|
|
140
|
+
linger after a crash).
|
|
141
|
+
|
|
142
|
+
Both checks are combined: if either signals a potential issue,
|
|
143
|
+
``True`` is returned.
|
|
144
|
+
"""
|
|
145
|
+
source_name = db_path.name
|
|
146
|
+
|
|
147
|
+
# Check 1: Is an editor process we launched still running?
|
|
148
|
+
if self.is_editor_running(source_name):
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
# Check 2: SQLite journal / WAL artefacts
|
|
152
|
+
journal = db_path.parent / (db_path.name + "-journal")
|
|
153
|
+
wal = db_path.parent / (db_path.name + "-wal")
|
|
154
|
+
for artefact in (journal, wal):
|
|
155
|
+
try:
|
|
156
|
+
if artefact.exists() and artefact.stat().st_size > 0:
|
|
157
|
+
return True
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# ── Cleanup ───────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def cleanup(self) -> None:
|
|
166
|
+
"""Stop tracking all processes (without killing them)."""
|
|
167
|
+
self._processes.clear()
|
|
168
|
+
for log_path in self._launch_logs.values():
|
|
169
|
+
try:
|
|
170
|
+
log_path.unlink(missing_ok=True)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
self._launch_logs.clear()
|
|
174
|
+
|
|
175
|
+
# ── Internals ─────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def _reap_dead(self, source_name: str) -> None:
|
|
178
|
+
"""Remove finished processes from the tracking list.
|
|
179
|
+
|
|
180
|
+
A process that exited cleanly has its launch log discarded here; a
|
|
181
|
+
non-zero exit keeps its log so :meth:`read_launch_output` can still
|
|
182
|
+
surface the failure to the user.
|
|
183
|
+
"""
|
|
184
|
+
procs = self._processes.get(source_name)
|
|
185
|
+
if procs is None:
|
|
186
|
+
return
|
|
187
|
+
alive: list[subprocess.Popen] = []
|
|
188
|
+
for p in procs:
|
|
189
|
+
if p.poll() is None:
|
|
190
|
+
alive.append(p)
|
|
191
|
+
elif p.returncode == 0:
|
|
192
|
+
self._drop_log(p)
|
|
193
|
+
self._processes[source_name] = alive
|
|
194
|
+
|
|
195
|
+
def _drop_log(self, proc: subprocess.Popen) -> None:
|
|
196
|
+
"""Discard the captured launch log for *proc*, if any."""
|
|
197
|
+
log_path = self._launch_logs.pop(proc, None)
|
|
198
|
+
if log_path is not None:
|
|
199
|
+
try:
|
|
200
|
+
log_path.unlink(missing_ok=True)
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|