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,424 @@
|
|
|
1
|
+
"""Reusable launcher for RP-preprocess and calibration auxiliary jobs.
|
|
2
|
+
|
|
3
|
+
The ``CalibrateDialog`` (a separate task) resolves per-scenario knobs and then
|
|
4
|
+
calls :func:`launch_rp_jobs` / :func:`launch_calibration_jobs` from here. This
|
|
5
|
+
module is the ONLY place that turns those resolved knobs into running
|
|
6
|
+
subprocesses; it does not re-render command lines — it delegates to the single
|
|
7
|
+
argv renderers in :mod:`flextool.gui.calibrate_commands`.
|
|
8
|
+
|
|
9
|
+
Why NOT the scheduler
|
|
10
|
+
---------------------
|
|
11
|
+
``ExecutionManager``'s scheduler dispatches only ``JobType.SCENARIO`` jobs
|
|
12
|
+
(``_pick_next_pending`` filters SCENARIO; ``any_active`` is SCENARIO-only). RP
|
|
13
|
+
and calibration runs therefore use the *auxiliary-job* pattern:
|
|
14
|
+
``add_auxiliary_job`` registers a tracked, streamable job entry, and THIS module
|
|
15
|
+
runs the subprocess in its own background thread — draining stdout line by line
|
|
16
|
+
into ``append_stdout`` and finalising with ``finish_job``. Aux jobs are
|
|
17
|
+
rendered generically by the execution window (only SCENARIO is special-cased),
|
|
18
|
+
so they reuse ``JobType.OUTPUT_ACTION`` and carry a descriptive
|
|
19
|
+
``display_name``; no new ``JobType`` is required.
|
|
20
|
+
|
|
21
|
+
Concurrency cap
|
|
22
|
+
---------------
|
|
23
|
+
Aux jobs bypass the scheduler's ``max_workers`` gate, so naively threading N
|
|
24
|
+
calibrations would launch all N heavy solves at once and thrash memory. Each
|
|
25
|
+
job kind is funnelled through a single module-global FIFO worker
|
|
26
|
+
(:class:`_SerialRunner`): calibrations run strictly one at a time, and RP jobs
|
|
27
|
+
(lighter, but serialized for the same simplicity) likewise. The cap is the
|
|
28
|
+
runner, not an incidental side effect — submitting M jobs enqueues M callables
|
|
29
|
+
that the one worker thread drains sequentially. Separate runners for RP and
|
|
30
|
+
calibration mean the two kinds don't block each other, only themselves.
|
|
31
|
+
|
|
32
|
+
Watchdog interaction
|
|
33
|
+
--------------------
|
|
34
|
+
``MemoryWatchdog`` picks its kill victim as the RUNNING job with the largest
|
|
35
|
+
``rss - memory_cap_gb`` overage under global memory pressure. A long
|
|
36
|
+
calibration has ``memory_cap_gb == 0`` (no reliable estimate), so its overage
|
|
37
|
+
would equal its whole RSS — it would be the automatic victim and get killed
|
|
38
|
+
mid-run. We take the smallest, least-surprising fix: mark these aux jobs
|
|
39
|
+
``watchdog_exempt = True`` (a one-field addition on ``ExecutionJob``). The
|
|
40
|
+
watchdog still tracks their peak RSS but never selects them to kill. We chose
|
|
41
|
+
this over faking a ``memory_cap_gb`` estimate (Option A) because a made-up
|
|
42
|
+
estimate would still be fragile — a calibration that legitimately grows past it
|
|
43
|
+
would become the victim again — whereas the exemption states the intent
|
|
44
|
+
directly: these user-initiated, non-scenario runs are not memory-managed.
|
|
45
|
+
|
|
46
|
+
Thread safety
|
|
47
|
+
-------------
|
|
48
|
+
All subprocess work happens off the Tk thread. ``append_stdout`` and
|
|
49
|
+
``finish_job`` lock internally, so the worker can call them freely.
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import logging
|
|
54
|
+
import os
|
|
55
|
+
import queue
|
|
56
|
+
import re
|
|
57
|
+
import subprocess
|
|
58
|
+
import threading
|
|
59
|
+
from collections.abc import Callable, Sequence
|
|
60
|
+
from dataclasses import dataclass, field
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
|
|
63
|
+
from flextool.gui.calibrate_commands import (
|
|
64
|
+
build_calibrate_command,
|
|
65
|
+
build_rp_command,
|
|
66
|
+
command_to_display_string,
|
|
67
|
+
overshoot_pct_to_multiplier,
|
|
68
|
+
)
|
|
69
|
+
from flextool.gui.execution_manager import ExecutionManager, JobType
|
|
70
|
+
|
|
71
|
+
logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
# Job specifications (what the dialog hands us)
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class RpJobSpec:
|
|
80
|
+
"""One representative-periods preprocess run.
|
|
81
|
+
|
|
82
|
+
``db_url`` / ``scenario`` identify the target; the remaining fields are the
|
|
83
|
+
resolved knobs consumed verbatim by :func:`build_rp_command`. RP writes back
|
|
84
|
+
into the database, so no per-scenario output directories are needed.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
db_url: str
|
|
88
|
+
scenario: str
|
|
89
|
+
n_rp: int
|
|
90
|
+
period_length: int
|
|
91
|
+
force_sustained: bool = False
|
|
92
|
+
force_peak: bool = False
|
|
93
|
+
force_window: int | None = None
|
|
94
|
+
solves: Sequence[str] = field(default_factory=list)
|
|
95
|
+
alternative_name: str | None = None
|
|
96
|
+
alternative_description: str | None = None
|
|
97
|
+
# Optional post-success hook, invoked with this spec's ``scenario`` AFTER
|
|
98
|
+
# the RP subprocess exits 0 and the job is finalised SUCCESS. The dialog
|
|
99
|
+
# uses it to append the freshly written RP alternative onto the scenario's
|
|
100
|
+
# stack (``add_alternative_to_scenario``) — a step that must NOT run before
|
|
101
|
+
# the async subprocess finishes, or "Run calibrations" would not see the
|
|
102
|
+
# new periods. It runs on the RP worker thread (off the Tk thread); any
|
|
103
|
+
# exception it raises is caught and streamed into the job log rather than
|
|
104
|
+
# crashing the worker (see ``_run_aux_subprocess``).
|
|
105
|
+
on_success: Callable[[str], None] | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CalibJobSpec:
|
|
110
|
+
"""One calibration run.
|
|
111
|
+
|
|
112
|
+
``db_url`` / ``scenario`` identify the target. ``overshoot_pct`` is the
|
|
113
|
+
dialog's planning-safety-margin PERCENT (0 = off); the launcher converts it
|
|
114
|
+
to the CLI multiplier via :func:`overshoot_pct_to_multiplier`. The three
|
|
115
|
+
per-scenario directories are derived under the project path by the launcher
|
|
116
|
+
(see :func:`_calib_dirs`) — leave them ``None`` to use the derived defaults,
|
|
117
|
+
or set them to override.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
db_url: str
|
|
121
|
+
scenario: str
|
|
122
|
+
iterations: int
|
|
123
|
+
sizing: str = "uniform"
|
|
124
|
+
overshoot_pct: float = 0.0
|
|
125
|
+
damping_first: float = 1.0
|
|
126
|
+
damping_remaining: float = 0.5
|
|
127
|
+
stall_fraction: float = 0.05
|
|
128
|
+
debug: bool = False
|
|
129
|
+
# Which formats the calibrator regenerates from the final parquet after the
|
|
130
|
+
# loop (the project's "File outputs" choices, parquet excluded — always
|
|
131
|
+
# present). ``None`` = inherit the CLI default (csv); ``[]`` = skip (leave
|
|
132
|
+
# results parquet-only); non-empty = those formats.
|
|
133
|
+
final_write_methods: list[str] | None = None
|
|
134
|
+
warm_start_cache_dir: str | None = None
|
|
135
|
+
work_dir: str | None = None
|
|
136
|
+
output_location: str | None = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------- #
|
|
140
|
+
# Per-scenario directory derivation
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
|
|
143
|
+
_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _safe_name(scenario: str) -> str:
|
|
147
|
+
"""Filesystem-safe slug for a scenario name (spaces / slashes → ``_``)."""
|
|
148
|
+
slug = _SAFE_RE.sub("_", scenario).strip("_")
|
|
149
|
+
return slug or "scenario"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _calib_dirs(
|
|
153
|
+
project_path: Path, scenario: str, *, create: bool = True
|
|
154
|
+
) -> tuple[str, str, str]:
|
|
155
|
+
"""Return ``(warm_start_cache_dir, work_dir, output_location)`` for a scenario.
|
|
156
|
+
|
|
157
|
+
* ``work_dir`` — ``<project>/work/calibrate_<scenario>`` (per-scenario, so
|
|
158
|
+
concurrent-nothing but repeat runs of different scenarios don't collide).
|
|
159
|
+
* ``warm_start_cache_dir`` — ``<work_dir>/warm_start_cache`` (the calibrate
|
|
160
|
+
CLI's own default relative to the work dir; made explicit here so the
|
|
161
|
+
copied command line is self-contained).
|
|
162
|
+
* ``output_location`` — the project root. The calibrate CLI writes solve
|
|
163
|
+
results under ``<output-location>/output_parquet/<scenario>/`` and report
|
|
164
|
+
CSVs under ``<output-location>/calibration_reports/<scenario>/``, matching
|
|
165
|
+
the scenario runner's convention of one shared output root (both trees are
|
|
166
|
+
per-scenario so calibrating several scenarios never overwrites).
|
|
167
|
+
|
|
168
|
+
When ``create`` is True (the launch path) all three directories are created
|
|
169
|
+
eagerly so the subprocess never races to ``mkdir`` a parent. The live CLI
|
|
170
|
+
preview passes ``create=False`` so refreshing it on every keystroke does
|
|
171
|
+
not litter empty directories.
|
|
172
|
+
"""
|
|
173
|
+
safe = _safe_name(scenario)
|
|
174
|
+
work_dir = project_path / "work" / f"calibrate_{safe}"
|
|
175
|
+
warm_start = work_dir / "warm_start_cache"
|
|
176
|
+
output_location = project_path
|
|
177
|
+
if create:
|
|
178
|
+
for d in (work_dir, warm_start, output_location / "output_parquet"):
|
|
179
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
return str(warm_start), str(work_dir), str(output_location)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
# Serial FIFO worker (the concurrency cap)
|
|
185
|
+
# --------------------------------------------------------------------------- #
|
|
186
|
+
|
|
187
|
+
class _SerialRunner:
|
|
188
|
+
"""A single background thread draining a FIFO queue of callables.
|
|
189
|
+
|
|
190
|
+
Guarantees that at most one submitted callable runs at a time and that they
|
|
191
|
+
run in submission order. The worker is a lazily-started daemon thread that
|
|
192
|
+
parks on the queue when idle; it dies with the process. ``submit`` is
|
|
193
|
+
thread-safe. ``wait`` (test helper) blocks until the queue is drained.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, name: str) -> None:
|
|
197
|
+
self._name = name
|
|
198
|
+
self._q: queue.Queue = queue.Queue()
|
|
199
|
+
self._lock = threading.Lock()
|
|
200
|
+
self._thread: threading.Thread | None = None
|
|
201
|
+
|
|
202
|
+
def submit(self, fn) -> None:
|
|
203
|
+
self._q.put(fn)
|
|
204
|
+
with self._lock:
|
|
205
|
+
if self._thread is None or not self._thread.is_alive():
|
|
206
|
+
self._thread = threading.Thread(
|
|
207
|
+
target=self._loop, name=self._name, daemon=True
|
|
208
|
+
)
|
|
209
|
+
self._thread.start()
|
|
210
|
+
|
|
211
|
+
def _loop(self) -> None:
|
|
212
|
+
while True:
|
|
213
|
+
fn = self._q.get()
|
|
214
|
+
try:
|
|
215
|
+
fn()
|
|
216
|
+
except Exception: # never let the worker thread die
|
|
217
|
+
logger.exception("%s: job callable raised", self._name)
|
|
218
|
+
finally:
|
|
219
|
+
self._q.task_done()
|
|
220
|
+
|
|
221
|
+
def wait(self, timeout: float | None = None) -> None:
|
|
222
|
+
"""Block until every submitted callable has finished (test helper)."""
|
|
223
|
+
# queue.Queue.join() has no timeout; emulate one so tests can't hang.
|
|
224
|
+
if timeout is None:
|
|
225
|
+
self._q.join()
|
|
226
|
+
return
|
|
227
|
+
deadline = threading.Event()
|
|
228
|
+
t = threading.Timer(timeout, deadline.set)
|
|
229
|
+
t.daemon = True
|
|
230
|
+
t.start()
|
|
231
|
+
try:
|
|
232
|
+
while self._q.unfinished_tasks and not deadline.is_set():
|
|
233
|
+
deadline.wait(0.02)
|
|
234
|
+
finally:
|
|
235
|
+
t.cancel()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Module-global runners: one per job kind, each serialising its own kind across
|
|
239
|
+
# every launch call for the process lifetime.
|
|
240
|
+
_CALIBRATE_RUNNER = _SerialRunner("flextool-calibrate")
|
|
241
|
+
_RP_RUNNER = _SerialRunner("flextool-rp")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
# The worker body: run one aux-job subprocess to completion
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
|
|
248
|
+
def _run_aux_subprocess(
|
|
249
|
+
mgr: ExecutionManager,
|
|
250
|
+
*,
|
|
251
|
+
display_name: str,
|
|
252
|
+
action_key: str,
|
|
253
|
+
argv: list[str],
|
|
254
|
+
cwd: str,
|
|
255
|
+
on_success: Callable[[], None] | None = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Register an auxiliary job, run ``argv`` as a subprocess, stream + finalise.
|
|
258
|
+
|
|
259
|
+
Robust by construction: any exception is caught, logged into the job's own
|
|
260
|
+
stream, and the job is finalised FAILED — this never raises out of the
|
|
261
|
+
worker thread. A nonzero exit (including the known intermittent exit-144)
|
|
262
|
+
finalises FAILED with the streamed log left intact.
|
|
263
|
+
|
|
264
|
+
``on_success`` (optional) is a zero-arg callable run AFTER the job is
|
|
265
|
+
finalised SUCCESS, on this same worker thread. It is where post-run wiring
|
|
266
|
+
(e.g. appending the RP alternative to the scenario stack) belongs, so it
|
|
267
|
+
cannot run before the subprocess has actually finished. Its own failure is
|
|
268
|
+
caught and streamed into the job log — it never propagates out of the
|
|
269
|
+
worker and never flips the already-recorded SUCCESS.
|
|
270
|
+
"""
|
|
271
|
+
job = mgr.add_auxiliary_job(JobType.OUTPUT_ACTION, display_name, action_key)
|
|
272
|
+
# Mark exempt from the memory watchdog (long run, no cap estimate).
|
|
273
|
+
job.watchdog_exempt = True
|
|
274
|
+
|
|
275
|
+
# Log the exact command first, mirroring the scenario runner.
|
|
276
|
+
mgr.append_stdout(job.job_id, command_to_display_string(argv))
|
|
277
|
+
mgr.append_stdout(job.job_id, "")
|
|
278
|
+
|
|
279
|
+
success = False
|
|
280
|
+
try:
|
|
281
|
+
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
|
282
|
+
proc = subprocess.Popen(
|
|
283
|
+
argv,
|
|
284
|
+
stdout=subprocess.PIPE,
|
|
285
|
+
stderr=subprocess.STDOUT,
|
|
286
|
+
text=True,
|
|
287
|
+
bufsize=1,
|
|
288
|
+
cwd=cwd,
|
|
289
|
+
env=env,
|
|
290
|
+
)
|
|
291
|
+
with mgr._lock:
|
|
292
|
+
job.process = proc
|
|
293
|
+
assert proc.stdout is not None
|
|
294
|
+
for line in proc.stdout:
|
|
295
|
+
mgr.append_stdout(job.job_id, line.rstrip("\n"))
|
|
296
|
+
return_code = proc.wait()
|
|
297
|
+
success = return_code == 0
|
|
298
|
+
if not success:
|
|
299
|
+
mgr.append_stdout(
|
|
300
|
+
job.job_id, f"Process exited with code {return_code}"
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc: # never propagate out of the worker
|
|
303
|
+
logger.exception("Auxiliary job '%s' failed", display_name)
|
|
304
|
+
mgr.append_stdout(job.job_id, f"Job failed with exception: {exc}")
|
|
305
|
+
success = False
|
|
306
|
+
finally:
|
|
307
|
+
with mgr._lock:
|
|
308
|
+
job.process = None
|
|
309
|
+
mgr.finish_job(job.job_id, success)
|
|
310
|
+
|
|
311
|
+
# Post-success wiring runs only after a clean, finalised success. A
|
|
312
|
+
# failure here is surfaced in the job log (not raised) so a broken scenario
|
|
313
|
+
# hand-off is visible without killing the worker or the other queued jobs.
|
|
314
|
+
if success and on_success is not None:
|
|
315
|
+
try:
|
|
316
|
+
on_success()
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
logger.exception("Post-success hook for '%s' failed", display_name)
|
|
319
|
+
mgr.append_stdout(
|
|
320
|
+
job.job_id, f"Post-run wiring failed: {exc}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --------------------------------------------------------------------------- #
|
|
325
|
+
# Public entry points
|
|
326
|
+
# --------------------------------------------------------------------------- #
|
|
327
|
+
|
|
328
|
+
def launch_rp_jobs(
|
|
329
|
+
mgr: ExecutionManager,
|
|
330
|
+
*,
|
|
331
|
+
python_exe: str,
|
|
332
|
+
project_path: Path,
|
|
333
|
+
jobs: Sequence[RpJobSpec],
|
|
334
|
+
runner: _SerialRunner | None = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Enqueue one serialized RP-preprocess aux job per scenario.
|
|
337
|
+
|
|
338
|
+
Returns immediately; the jobs run one at a time on the RP runner. The
|
|
339
|
+
optional ``runner`` override exists for tests (inject a local
|
|
340
|
+
:class:`_SerialRunner` to wait on it deterministically).
|
|
341
|
+
"""
|
|
342
|
+
run = runner if runner is not None else _RP_RUNNER
|
|
343
|
+
cwd = str(project_path)
|
|
344
|
+
for spec in jobs:
|
|
345
|
+
argv = build_rp_command(
|
|
346
|
+
python_exe,
|
|
347
|
+
spec.db_url,
|
|
348
|
+
spec.scenario,
|
|
349
|
+
n_rp=spec.n_rp,
|
|
350
|
+
period_length=spec.period_length,
|
|
351
|
+
force_sustained=spec.force_sustained,
|
|
352
|
+
force_peak=spec.force_peak,
|
|
353
|
+
force_window=spec.force_window,
|
|
354
|
+
solves=list(spec.solves),
|
|
355
|
+
alternative_name=spec.alternative_name,
|
|
356
|
+
alternative_description=spec.alternative_description,
|
|
357
|
+
)
|
|
358
|
+
display_name = f"RP: {spec.scenario}"
|
|
359
|
+
action_key = f"rp:{spec.scenario}"
|
|
360
|
+
# Bind the spec's (scenario)->None hook into the zero-arg callable the
|
|
361
|
+
# worker invokes, capturing this spec's scenario name. ``None`` when no
|
|
362
|
+
# hook was supplied.
|
|
363
|
+
hook = spec.on_success
|
|
364
|
+
scenario = spec.scenario
|
|
365
|
+
on_success = (
|
|
366
|
+
(lambda h=hook, s=scenario: h(s)) if hook is not None else None
|
|
367
|
+
)
|
|
368
|
+
run.submit(
|
|
369
|
+
lambda mgr=mgr, dn=display_name, ak=action_key, av=argv,
|
|
370
|
+
os_=on_success: (
|
|
371
|
+
_run_aux_subprocess(
|
|
372
|
+
mgr, display_name=dn, action_key=ak, argv=av, cwd=cwd,
|
|
373
|
+
on_success=os_,
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def launch_calibration_jobs(
|
|
380
|
+
mgr: ExecutionManager,
|
|
381
|
+
*,
|
|
382
|
+
python_exe: str,
|
|
383
|
+
project_path: Path,
|
|
384
|
+
jobs: Sequence[CalibJobSpec],
|
|
385
|
+
runner: _SerialRunner | None = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Enqueue one serialized calibration aux job per scenario.
|
|
388
|
+
|
|
389
|
+
Returns immediately; calibrations run strictly one at a time on the
|
|
390
|
+
calibration runner. Per-scenario directories are derived (and created)
|
|
391
|
+
under ``project_path`` unless the spec overrides them. The optional
|
|
392
|
+
``runner`` override exists for tests.
|
|
393
|
+
"""
|
|
394
|
+
run = runner if runner is not None else _CALIBRATE_RUNNER
|
|
395
|
+
cwd = str(project_path)
|
|
396
|
+
for spec in jobs:
|
|
397
|
+
warm_start, work_dir, output_location = _calib_dirs(
|
|
398
|
+
project_path, spec.scenario
|
|
399
|
+
)
|
|
400
|
+
argv = build_calibrate_command(
|
|
401
|
+
python_exe,
|
|
402
|
+
spec.db_url,
|
|
403
|
+
spec.scenario,
|
|
404
|
+
iterations=spec.iterations,
|
|
405
|
+
sizing=spec.sizing,
|
|
406
|
+
overshoot=overshoot_pct_to_multiplier(spec.overshoot_pct),
|
|
407
|
+
damping_first=spec.damping_first,
|
|
408
|
+
damping_remaining=spec.damping_remaining,
|
|
409
|
+
stall_fraction=spec.stall_fraction,
|
|
410
|
+
warm_start_cache_dir=spec.warm_start_cache_dir or warm_start,
|
|
411
|
+
work_dir=spec.work_dir or work_dir,
|
|
412
|
+
output_location=spec.output_location or output_location,
|
|
413
|
+
debug=spec.debug,
|
|
414
|
+
final_write_methods=spec.final_write_methods,
|
|
415
|
+
)
|
|
416
|
+
display_name = f"Calibrate: {spec.scenario}"
|
|
417
|
+
action_key = f"calibrate:{spec.scenario}"
|
|
418
|
+
run.submit(
|
|
419
|
+
lambda mgr=mgr, dn=display_name, ak=action_key, av=argv: (
|
|
420
|
+
_run_aux_subprocess(
|
|
421
|
+
mgr, display_name=dn, action_key=ak, argv=av, cwd=cwd
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Reusable controller for Treeview widgets with a checkbox column."""
|
|
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 CheckTreeController:
|
|
10
|
+
"""Wires consistent check/uncheck behaviour onto a ttk.Treeview.
|
|
11
|
+
|
|
12
|
+
The tree must have a column dedicated to a check glyph (default
|
|
13
|
+
column id ``"check"``). The controller installs:
|
|
14
|
+
|
|
15
|
+
- A ``<Button-1>`` handler that toggles a single row's check **only
|
|
16
|
+
if the click landed inside the check column**. Clicks anywhere
|
|
17
|
+
else fall through to the Treeview's default selection logic, so
|
|
18
|
+
shift/ctrl multi-select keeps working.
|
|
19
|
+
- A ``<space>`` handler that applies the multi-select toggle rule:
|
|
20
|
+
all selected rows checked -> uncheck all
|
|
21
|
+
all selected rows unchecked -> check all
|
|
22
|
+
mixed -> check all
|
|
23
|
+
|
|
24
|
+
The controller never persists state itself; it calls back into the
|
|
25
|
+
caller via ``on_toggle(changed_iids)`` so the host widget can save
|
|
26
|
+
settings, refresh the display, etc.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
tree: ttk.Treeview,
|
|
32
|
+
*,
|
|
33
|
+
check_column: str = "check",
|
|
34
|
+
checked_glyph: str = "■",
|
|
35
|
+
unchecked_glyph: str = "□",
|
|
36
|
+
on_toggle: Callable[[list[str]], None] | None = None,
|
|
37
|
+
can_check: Callable[[str], bool] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._tree = tree
|
|
40
|
+
self._check_column = check_column
|
|
41
|
+
self._checked = checked_glyph
|
|
42
|
+
self._unchecked = unchecked_glyph
|
|
43
|
+
self._on_toggle = on_toggle
|
|
44
|
+
# Optional predicate: rows for which it returns False can never be
|
|
45
|
+
# checked/unchecked (e.g. informational "ghost" rows).
|
|
46
|
+
self._can_check = can_check
|
|
47
|
+
|
|
48
|
+
tree.bind("<Button-1>", self._on_click, add="+")
|
|
49
|
+
tree.bind("<space>", self._on_space, add="+")
|
|
50
|
+
tree.bind("<Key-space>", self._on_space, add="+")
|
|
51
|
+
|
|
52
|
+
# ---- public helpers --------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def is_checked(self, iid: str) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
value = self._tree.set(iid, self._check_column)
|
|
57
|
+
except tk.TclError:
|
|
58
|
+
return False
|
|
59
|
+
return value == self._checked
|
|
60
|
+
|
|
61
|
+
def set_checked(self, iid: str, value: bool, *, notify: bool = True) -> None:
|
|
62
|
+
glyph = self._checked if value else self._unchecked
|
|
63
|
+
try:
|
|
64
|
+
self._tree.set(iid, self._check_column, glyph)
|
|
65
|
+
except tk.TclError:
|
|
66
|
+
return
|
|
67
|
+
if notify and self._on_toggle is not None:
|
|
68
|
+
self._on_toggle([iid])
|
|
69
|
+
|
|
70
|
+
def toggle_selected(self) -> None:
|
|
71
|
+
sel = list(self._tree.selection())
|
|
72
|
+
if not sel:
|
|
73
|
+
return
|
|
74
|
+
sel = [iid for iid in sel if self._can_check is None or self._can_check(iid)]
|
|
75
|
+
if not sel:
|
|
76
|
+
return
|
|
77
|
+
all_checked = all(self.is_checked(iid) for iid in sel)
|
|
78
|
+
target = not all_checked # all checked -> uncheck all; else check all
|
|
79
|
+
changed: list[str] = []
|
|
80
|
+
for iid in sel:
|
|
81
|
+
if self.is_checked(iid) != target:
|
|
82
|
+
self._tree.set(
|
|
83
|
+
iid,
|
|
84
|
+
self._check_column,
|
|
85
|
+
self._checked if target else self._unchecked,
|
|
86
|
+
)
|
|
87
|
+
changed.append(iid)
|
|
88
|
+
if changed and self._on_toggle is not None:
|
|
89
|
+
self._on_toggle(changed)
|
|
90
|
+
|
|
91
|
+
# ---- internal handlers ----------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _on_click(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
|
|
94
|
+
# Only intercept clicks that land in the check column on a row.
|
|
95
|
+
# Anything else (heading, body of other columns, blank area) is
|
|
96
|
+
# passed through so default Treeview behaviour (single/extend
|
|
97
|
+
# selection) handles it.
|
|
98
|
+
region = self._tree.identify("region", event.x, event.y)
|
|
99
|
+
if region != "cell":
|
|
100
|
+
return None
|
|
101
|
+
column = self._tree.identify_column(event.x)
|
|
102
|
+
# column is like "#1"; resolve to the column id
|
|
103
|
+
col_index_str = column.lstrip("#")
|
|
104
|
+
try:
|
|
105
|
+
col_index = int(col_index_str) - 1
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
columns = self._tree["columns"]
|
|
109
|
+
if col_index < 0 or col_index >= len(columns):
|
|
110
|
+
return None
|
|
111
|
+
if columns[col_index] != self._check_column:
|
|
112
|
+
return None
|
|
113
|
+
iid = self._tree.identify_row(event.y)
|
|
114
|
+
if not iid:
|
|
115
|
+
return None
|
|
116
|
+
if self._can_check is not None and not self._can_check(iid):
|
|
117
|
+
return "break" # row is not checkable (e.g. ghost row)
|
|
118
|
+
new_value = not self.is_checked(iid)
|
|
119
|
+
self.set_checked(iid, new_value, notify=True)
|
|
120
|
+
return "break" # don't let default click also alter selection
|
|
121
|
+
|
|
122
|
+
def _on_space(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
|
|
123
|
+
self.toggle_selected()
|
|
124
|
+
return "break"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_check_tree(
|
|
128
|
+
tree: ttk.Treeview,
|
|
129
|
+
on_toggle: Callable[[list[str]], None] | None = None,
|
|
130
|
+
*,
|
|
131
|
+
check_column: str = "check",
|
|
132
|
+
checked_glyph: str = "■",
|
|
133
|
+
unchecked_glyph: str = "□",
|
|
134
|
+
) -> CheckTreeController:
|
|
135
|
+
"""Convenience: build + return a controller in one call."""
|
|
136
|
+
return CheckTreeController(
|
|
137
|
+
tree,
|
|
138
|
+
check_column=check_column,
|
|
139
|
+
checked_glyph=checked_glyph,
|
|
140
|
+
unchecked_glyph=unchecked_glyph,
|
|
141
|
+
on_toggle=on_toggle,
|
|
142
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Format CLI command lists for readable log output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _is_program_group(group: list[str]) -> bool:
|
|
6
|
+
"""True when *group* holds the program invocation (``python -m mod`` or
|
|
7
|
+
a ``…/script.py`` form)."""
|
|
8
|
+
return any(tok == "-m" or tok.endswith(".py") for tok in group)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _split_program_positionals(group: list[str]) -> tuple[list[str], list[str]]:
|
|
12
|
+
"""Split a program-invocation *group* into ``(head, positionals)``.
|
|
13
|
+
|
|
14
|
+
``head`` is the interpreter + ``-m module`` (or ``script.py``) prefix;
|
|
15
|
+
``positionals`` are the obligatory positional arguments that follow it
|
|
16
|
+
(e.g. the input-DB URL passed to ``cmd_run_flextool``). Flags already
|
|
17
|
+
start their own group upstream, so everything after the program entry
|
|
18
|
+
in this group is positional.
|
|
19
|
+
"""
|
|
20
|
+
end: int | None = None
|
|
21
|
+
for i, tok in enumerate(group):
|
|
22
|
+
if tok == "-m" and i + 1 < len(group):
|
|
23
|
+
end = i + 2 # interpreter … -m module
|
|
24
|
+
break
|
|
25
|
+
if tok.endswith(".py"):
|
|
26
|
+
end = i + 1
|
|
27
|
+
break
|
|
28
|
+
if end is None:
|
|
29
|
+
return group, []
|
|
30
|
+
return group[:end], group[end:]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_cmd_for_log(cmd: list[str]) -> str:
|
|
34
|
+
"""Format a command list into a multi-line string with bash \\ continuations.
|
|
35
|
+
|
|
36
|
+
Groups each ``--flag`` with its argument values on the same line, and
|
|
37
|
+
breaks the program's obligatory positional argument(s) onto their own
|
|
38
|
+
lines, indented one space deeper than flag continuations so they read
|
|
39
|
+
as arguments to the program rather than flags. The result is
|
|
40
|
+
copy-pasteable into a bash terminal.
|
|
41
|
+
|
|
42
|
+
Example::
|
|
43
|
+
|
|
44
|
+
systemd-run --user --scope --quiet \\
|
|
45
|
+
-- /path/python -m flextool.cli.cmd_run_flextool \\
|
|
46
|
+
sqlite:////path/to/input_data.sqlite \\
|
|
47
|
+
--scenario-name DES-Inv \\
|
|
48
|
+
--work-folder /path/to/work
|
|
49
|
+
"""
|
|
50
|
+
if not cmd:
|
|
51
|
+
return ''
|
|
52
|
+
|
|
53
|
+
# Group tokens: each ``--flag`` (and the bare ``--`` separator) starts a
|
|
54
|
+
# new group that carries its trailing argument values.
|
|
55
|
+
groups: list[list[str]] = []
|
|
56
|
+
current: list[str] = []
|
|
57
|
+
for token in cmd:
|
|
58
|
+
if token.startswith('--') and current:
|
|
59
|
+
groups.append(current)
|
|
60
|
+
current = [token]
|
|
61
|
+
else:
|
|
62
|
+
current.append(token)
|
|
63
|
+
if current:
|
|
64
|
+
groups.append(current)
|
|
65
|
+
|
|
66
|
+
# Render each group to a (indent, text) continuation line; positional
|
|
67
|
+
# arguments of the program get a deeper indent than flag continuations.
|
|
68
|
+
rendered: list[str] = []
|
|
69
|
+
for idx, group in enumerate(groups):
|
|
70
|
+
indent = '' if idx == 0 else ' '
|
|
71
|
+
if _is_program_group(group):
|
|
72
|
+
head, positionals = _split_program_positionals(group)
|
|
73
|
+
rendered.append(indent + ' '.join(head))
|
|
74
|
+
for pos in positionals:
|
|
75
|
+
rendered.append(' ' + pos)
|
|
76
|
+
else:
|
|
77
|
+
rendered.append(indent + ' '.join(group))
|
|
78
|
+
|
|
79
|
+
if len(rendered) <= 1:
|
|
80
|
+
return rendered[0].strip() if rendered else ''
|
|
81
|
+
|
|
82
|
+
# Join with ``\`` continuations; indents are already baked into each line.
|
|
83
|
+
return ' \\\n'.join(rendered)
|