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,134 @@
|
|
|
1
|
+
"""Shell out to ``cmd_run_flextool`` for one calibrator iteration.
|
|
2
|
+
|
|
3
|
+
The calibrator does **not** solve in-process: it launches a fresh
|
|
4
|
+
:mod:`flextool.cli.cmd_run_flextool` subprocess per iteration (a clean
|
|
5
|
+
address space per solve, matching how the model is run in production) and
|
|
6
|
+
then reads the produced parquet outputs. This module owns that launch —
|
|
7
|
+
building the argv, wiring the warm-start environment, capturing the launch
|
|
8
|
+
time (needed by the solve-success detector's freshness check), and running
|
|
9
|
+
the subprocess with its stdout+stderr merged into one captured stream.
|
|
10
|
+
|
|
11
|
+
Warm start
|
|
12
|
+
----------
|
|
13
|
+
Warm start is enabled via the environment, not the ``--warm-start`` CLI
|
|
14
|
+
flag, because it is the env vars the engine actually reads
|
|
15
|
+
(``FLEXTOOL_WARM_START`` / ``FLEXTOOL_BASIS_CACHE_DIR`` in
|
|
16
|
+
``flextool.engine_polars._orchestration``). A *stable* basis-cache
|
|
17
|
+
directory shared across iterations lets HiGHS reuse the previous
|
|
18
|
+
iteration's basis when the structural model is unchanged (the adder is
|
|
19
|
+
RHS-only, so the warm-start fingerprint is stable across iterations).
|
|
20
|
+
|
|
21
|
+
``FLEXTOOL_SAVE_MEMORY`` is deliberately **not** set here: it releases the
|
|
22
|
+
live HiGHS instance after each sub-solve and so DISABLES warm-LP reuse. It
|
|
23
|
+
must also stay constant (unset) across every iteration — flipping it
|
|
24
|
+
mid-run would invalidate the shared basis cache.
|
|
25
|
+
|
|
26
|
+
This module does not judge success: it returns the raw
|
|
27
|
+
:class:`SolveRun` and lets the loop call
|
|
28
|
+
:func:`flextool.calibrate.assess_solve` so the loop owns the
|
|
29
|
+
``required_outputs`` choice.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
# Repo root = <root>/flextool/calibrate/_solve.py → parents[2].
|
|
42
|
+
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SolveRun:
|
|
47
|
+
"""Raw record of one ``cmd_run_flextool`` subprocess.
|
|
48
|
+
|
|
49
|
+
``returncode`` — the subprocess exit code (a weak success signal; see
|
|
50
|
+
:mod:`flextool.calibrate._solve_status`).
|
|
51
|
+
``started_at`` — POSIX wall-clock time captured immediately before the
|
|
52
|
+
subprocess launched, for the detector's freshness check.
|
|
53
|
+
``assess_dir`` — the directory that directly holds this run's result
|
|
54
|
+
parquets (``<out_root>/output_parquet/<scenario>``).
|
|
55
|
+
``stdout`` — the merged stdout+stderr text of the subprocess.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
returncode: int
|
|
59
|
+
started_at: float
|
|
60
|
+
assess_dir: Path
|
|
61
|
+
stdout: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_solve(
|
|
65
|
+
url: str,
|
|
66
|
+
scenario: str,
|
|
67
|
+
*,
|
|
68
|
+
work_dir: Path,
|
|
69
|
+
out_root: Path,
|
|
70
|
+
cache_dir: Path,
|
|
71
|
+
) -> SolveRun:
|
|
72
|
+
"""Run one calibrator solve and return its raw :class:`SolveRun`.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
url:
|
|
77
|
+
Input SpineDB — a bare path (promoted to ``sqlite:///``) or a full
|
|
78
|
+
SQLAlchemy URL.
|
|
79
|
+
scenario:
|
|
80
|
+
The model scenario to solve.
|
|
81
|
+
work_dir:
|
|
82
|
+
Working directory for the subprocess's intermediate files
|
|
83
|
+
(``--work-folder``).
|
|
84
|
+
out_root:
|
|
85
|
+
Output-location root (``--output-location``); results land under
|
|
86
|
+
``out_root/output_parquet/<scenario>/``.
|
|
87
|
+
cache_dir:
|
|
88
|
+
Warm-start basis-cache directory, shared across iterations
|
|
89
|
+
(``FLEXTOOL_BASIS_CACHE_DIR``). Keep it stable across the whole
|
|
90
|
+
calibration run so HiGHS can reuse the prior iteration's basis.
|
|
91
|
+
"""
|
|
92
|
+
url_norm = url if "://" in url else f"sqlite:///{url}"
|
|
93
|
+
argv = [
|
|
94
|
+
sys.executable,
|
|
95
|
+
"-m",
|
|
96
|
+
"flextool.cli.cmd_run_flextool",
|
|
97
|
+
url_norm,
|
|
98
|
+
"--scenario-name",
|
|
99
|
+
scenario,
|
|
100
|
+
"--work-folder",
|
|
101
|
+
str(work_dir),
|
|
102
|
+
"--output-location",
|
|
103
|
+
str(out_root),
|
|
104
|
+
"--write-methods",
|
|
105
|
+
"parquet",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
env = os.environ.copy()
|
|
109
|
+
env["FLEXTOOL_WARM_START"] = "1"
|
|
110
|
+
env["FLEXTOOL_BASIS_CACHE_DIR"] = str(cache_dir)
|
|
111
|
+
# FLEXTOOL_SAVE_MEMORY is intentionally left untouched: setting it would
|
|
112
|
+
# disable warm-LP reuse, and it must stay constant across iterations.
|
|
113
|
+
|
|
114
|
+
assess_dir = Path(out_root) / "output_parquet" / scenario
|
|
115
|
+
|
|
116
|
+
started_at = time.time()
|
|
117
|
+
proc = subprocess.run(
|
|
118
|
+
argv,
|
|
119
|
+
cwd=str(_REPO_ROOT),
|
|
120
|
+
env=env,
|
|
121
|
+
stdout=subprocess.PIPE,
|
|
122
|
+
stderr=subprocess.STDOUT,
|
|
123
|
+
text=True,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return SolveRun(
|
|
127
|
+
returncode=proc.returncode,
|
|
128
|
+
started_at=started_at,
|
|
129
|
+
assess_dir=assess_dir,
|
|
130
|
+
stdout=proc.stdout or "",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["SolveRun", "run_solve"]
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Resilient solve-success detection for the energy-margin calibrator.
|
|
2
|
+
|
|
3
|
+
The calibrator runs an investment+dispatch solve each iteration by
|
|
4
|
+
*shelling out* to :mod:`flextool.cli.cmd_run_flextool` and then reads the
|
|
5
|
+
per-node unserved-energy slack from the produced outputs. Before it can
|
|
6
|
+
trust those numbers it must answer one question: **did this solve actually
|
|
7
|
+
succeed?** This module is that answer.
|
|
8
|
+
|
|
9
|
+
Why the subprocess exit code is not enough
|
|
10
|
+
------------------------------------------
|
|
11
|
+
Success is ``f(solve-status signals, output completeness)`` with the exit
|
|
12
|
+
code as only *one weak input*, because the exit code lies in both
|
|
13
|
+
directions:
|
|
14
|
+
|
|
15
|
+
* **False failure (nonzero exit, good solve).** A known *model-specific*
|
|
16
|
+
post-solve writer bug — ``Shared-alternative write failed: '<REG>'``
|
|
17
|
+
``KeyError`` in the separate PLEXOS→FlexTool writer, **not** in this
|
|
18
|
+
engine — can raise *after* the cascade has solved and written every
|
|
19
|
+
output, bubbling to a nonzero exit. The results on disk are complete
|
|
20
|
+
and usable; the run must be treated as a success.
|
|
21
|
+
|
|
22
|
+
* **False success (zero exit, missing results).** A run that never
|
|
23
|
+
reached, or aborted inside, output writing can still exit cleanly in
|
|
24
|
+
some paths; if the calibrator's required result files are absent it must
|
|
25
|
+
be treated as a failure regardless of the exit code.
|
|
26
|
+
|
|
27
|
+
What FlexTool actually leaves on disk
|
|
28
|
+
-------------------------------------
|
|
29
|
+
FlexTool does **not** persist a per-sub-solve optimality/acceptance status
|
|
30
|
+
file. The authoritative "was this solve acceptable" decision
|
|
31
|
+
(:func:`flextool.engine_polars._solve_acceptance.classify_acceptance`, run
|
|
32
|
+
at the solve site, and the cascade exit-scan
|
|
33
|
+
``flextool.cli.cmd_run_flextool._scan_cascade_optimality`` that consumes
|
|
34
|
+
it) lives *in memory* and is surfaced only via:
|
|
35
|
+
|
|
36
|
+
* the process **exit code** (0 iff every sub-solve was ``kOptimal``,
|
|
37
|
+
accepted near-optimal, or a Benders solve with a feasible incumbent;
|
|
38
|
+
1 on a genuine failure), and
|
|
39
|
+
* **log lines** on stdout/stderr.
|
|
40
|
+
|
|
41
|
+
Crucially, ``cmd_run_flextool`` calls ``write_outputs`` **only when the
|
|
42
|
+
cascade returned success** — a genuinely failed / infeasible / unaccepted
|
|
43
|
+
solve short-circuits with ``return_code == 1`` and writes *no* output
|
|
44
|
+
files at all. Therefore, on a fresh output directory, the **presence and
|
|
45
|
+
non-emptiness of the required result parquets is itself the on-disk
|
|
46
|
+
signal that every sub-solve was accepted**: a failed sub-solve manifests
|
|
47
|
+
as *missing outputs*, not as a status flag.
|
|
48
|
+
|
|
49
|
+
The detector's rule
|
|
50
|
+
-------------------
|
|
51
|
+
``outputs_complete`` = every required output parquet is present, is a
|
|
52
|
+
readable parquet with at least one row, and (when ``started_at`` is given)
|
|
53
|
+
was written by *this* run rather than left over from a previous one:
|
|
54
|
+
|
|
55
|
+
* not complete → **failed** (name the offending files);
|
|
56
|
+
* complete + exit 0/``None`` → **succeeded** (``started_at`` optional);
|
|
57
|
+
* complete + nonzero exit + fresh → **succeeded**, the nonzero exit is
|
|
58
|
+
recorded as *overridden* (the post-solve-writer-crash case);
|
|
59
|
+
* complete + nonzero exit + freshness UNVERIFIABLE (no ``started_at``)
|
|
60
|
+
→ **failed** — the override is refused because it cannot be made safely.
|
|
61
|
+
|
|
62
|
+
Stale-output caveat (why ``started_at`` matters)
|
|
63
|
+
------------------------------------------------
|
|
64
|
+
When a solve genuinely fails, ``write_outputs`` is skipped and the parquet
|
|
65
|
+
directory is **not** emptied, so a *previous* successful run's files can
|
|
66
|
+
linger. "outputs complete + nonzero exit" would then wrongly look like
|
|
67
|
+
the writer-crash case. So the nonzero-exit → success override is allowed
|
|
68
|
+
ONLY when ``started_at`` (the wall-clock time the calibrator launched the
|
|
69
|
+
subprocess) was supplied AND every required output is at least that new; a
|
|
70
|
+
stale file fails the completeness check, and a nonzero exit with no
|
|
71
|
+
``started_at`` at all is treated as a **failure** (freshness unverifiable —
|
|
72
|
+
the override cannot be made safely). **C1 must always pass ``started_at``.**
|
|
73
|
+
The exit-0 / no-exit path does not need it: a successful ``write_outputs``
|
|
74
|
+
empties then rewrites the parquet dir, so its files are this run's product
|
|
75
|
+
by construction.
|
|
76
|
+
|
|
77
|
+
This module never solves and never touches the network: it is a pure
|
|
78
|
+
post-hoc reader of a solve's output directory.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import logging
|
|
84
|
+
from dataclasses import dataclass, field
|
|
85
|
+
from datetime import datetime
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
from typing import Sequence
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
# The calibrator's load-bearing signals: per-period node up-slack (unserved
|
|
92
|
+
# energy) and the discounted per-entity node cost table (its 'upward slack
|
|
93
|
+
# penalty' category is the monetised slack). Held as REGISTRY keys — the
|
|
94
|
+
# on-disk filenames are resolved through the parquet-bundle registry so a
|
|
95
|
+
# schema/rename breaks loudly *here* rather than silently missing a file.
|
|
96
|
+
# Only ``node_slack_up_d_e`` is a robust success gate: it is *dense* — one
|
|
97
|
+
# row per period for every balance node, emitted unconditionally
|
|
98
|
+
# (out_node.py, ``v.q_state_up`` clipped ≥0) — so a valid solve NEVER omits
|
|
99
|
+
# it, even at zero slack. ``cost_node_discounted_d_ec`` is deliberately NOT
|
|
100
|
+
# a default requirement: out_costs.py skips a node cost category with an
|
|
101
|
+
# ``if not pieces: continue`` guard, so a legitimate solve can omit that
|
|
102
|
+
# table → it would cause a false FAIL. The calibrator READS it for the
|
|
103
|
+
# penalty M€, but presence of the slack table is the success signal.
|
|
104
|
+
_DEFAULT_REQUIRED_KEYS: tuple[str, ...] = (
|
|
105
|
+
"node_slack_up_d_e",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _registry_filename(key: str) -> str:
|
|
110
|
+
"""Resolve a processed-output *key* to its on-disk parquet basename.
|
|
111
|
+
|
|
112
|
+
Validated against
|
|
113
|
+
:data:`flextool.process_outputs._output_meta.OUTPUT_TRANSFORM` — the
|
|
114
|
+
single-source registry of every processed output table name (the same
|
|
115
|
+
keys ``write_outputs`` uses when it writes ``<key>.parquet``). We use
|
|
116
|
+
this rather than
|
|
117
|
+
:data:`flextool.engine_polars._parquet_bundle.REGISTRY`, whose
|
|
118
|
+
processed-output coverage is documented as REPRESENTATIVE / incomplete
|
|
119
|
+
by design (``cost_node_discounted_d_ec`` is absent there). A key not in
|
|
120
|
+
the registry raises loudly here instead of silently looking for a file
|
|
121
|
+
that can never exist — so a schema rename that updates ``OUTPUT_TRANSFORM``
|
|
122
|
+
surfaces as a clear error at the calibrator boundary.
|
|
123
|
+
"""
|
|
124
|
+
from flextool.process_outputs._output_meta import OUTPUT_TRANSFORM
|
|
125
|
+
|
|
126
|
+
if key not in OUTPUT_TRANSFORM:
|
|
127
|
+
raise KeyError(
|
|
128
|
+
f"{key!r} is not a registered FlexTool output "
|
|
129
|
+
"(flextool.process_outputs._output_meta.OUTPUT_TRANSFORM). "
|
|
130
|
+
"The calibrator's required-output default is stale; update "
|
|
131
|
+
"_DEFAULT_REQUIRED_KEYS or pass required_outputs explicitly."
|
|
132
|
+
)
|
|
133
|
+
return f"{key}.parquet"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def default_required_outputs() -> tuple[str, ...]:
|
|
137
|
+
"""The default required-output filenames, resolved via the registry.
|
|
138
|
+
|
|
139
|
+
Returns the ``*.parquet`` basenames the calibrator minimally needs to
|
|
140
|
+
trust a solve: the node up-slack and the discounted node-cost table.
|
|
141
|
+
"""
|
|
142
|
+
return tuple(_registry_filename(k) for k in _DEFAULT_REQUIRED_KEYS)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _normalise_required(
|
|
146
|
+
required_outputs: Sequence[str] | None,
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
"""Normalise the caller's required-output list to ``*.parquet`` basenames.
|
|
149
|
+
|
|
150
|
+
Accepts either REGISTRY keys (e.g. ``"node_slack_up_d_e"``) or explicit
|
|
151
|
+
filenames (e.g. ``"node_slack_up_d_e.parquet"``); a bare key is resolved
|
|
152
|
+
through the registry, a ``*.parquet`` name is taken verbatim. ``None``
|
|
153
|
+
yields :func:`default_required_outputs`.
|
|
154
|
+
"""
|
|
155
|
+
if required_outputs is None:
|
|
156
|
+
return list(default_required_outputs())
|
|
157
|
+
resolved: list[str] = []
|
|
158
|
+
for item in required_outputs:
|
|
159
|
+
name = str(item)
|
|
160
|
+
if name.endswith(".parquet"):
|
|
161
|
+
resolved.append(name)
|
|
162
|
+
else:
|
|
163
|
+
resolved.append(_registry_filename(name))
|
|
164
|
+
return resolved
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _as_epoch(started_at: float | int | datetime | None) -> float | None:
|
|
168
|
+
"""Coerce a ``started_at`` marker to a POSIX timestamp, or ``None``.
|
|
169
|
+
|
|
170
|
+
The recommended input (what C1 passes) is a plain epoch ``float`` from
|
|
171
|
+
:func:`time.time`, which compares directly against ``st_mtime``. A
|
|
172
|
+
tz-aware :class:`datetime` also compares correctly. A *naive* datetime
|
|
173
|
+
is interpreted as LOCAL time via :meth:`datetime.timestamp` — the same
|
|
174
|
+
convention ``st_mtime`` follows on POSIX — so it is safe (not silently
|
|
175
|
+
skewed); prefer the epoch float to avoid any ambiguity.
|
|
176
|
+
"""
|
|
177
|
+
if started_at is None:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(started_at, datetime):
|
|
180
|
+
# ``.timestamp()`` assumes LOCAL time for a naive datetime, matching
|
|
181
|
+
# how ``st_mtime`` (also epoch) relates to local wall-clock; a
|
|
182
|
+
# tz-aware datetime converts exactly. No skew either way.
|
|
183
|
+
return started_at.timestamp()
|
|
184
|
+
return float(started_at)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class OutputCheck:
|
|
189
|
+
"""Per-required-output evidence gathered from the output directory.
|
|
190
|
+
|
|
191
|
+
FlexTool persists no per-*sub-solve* status to disk (see the module
|
|
192
|
+
docstring), so this per-*output* record is the finest-grained on-disk
|
|
193
|
+
success evidence available. It is what :attr:`SolveOutcome.per_solve`
|
|
194
|
+
carries.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
filename: str
|
|
198
|
+
present: bool
|
|
199
|
+
num_rows: int | None
|
|
200
|
+
fresh: bool
|
|
201
|
+
detail: str
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def ok(self) -> bool:
|
|
205
|
+
"""Whether this output counts toward completeness.
|
|
206
|
+
|
|
207
|
+
Requires the file to be present, a readable parquet with at least
|
|
208
|
+
one row, and (subject to ``started_at``) fresh.
|
|
209
|
+
"""
|
|
210
|
+
return self.present and (self.num_rows or 0) > 0 and self.fresh
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class SolveOutcome:
|
|
215
|
+
"""Verdict on a completed (or crashed) FlexTool solve run.
|
|
216
|
+
|
|
217
|
+
``succeeded`` — whether the calibrator may consume this run's
|
|
218
|
+
results.
|
|
219
|
+
``reason`` — human-readable justification for the verdict.
|
|
220
|
+
``exit_code`` — the subprocess exit code, if the caller supplied
|
|
221
|
+
it (a weak input only).
|
|
222
|
+
``per_solve`` — per-required-output evidence (:class:`OutputCheck`
|
|
223
|
+
list). FlexTool exposes no on-disk per-sub-solve
|
|
224
|
+
optimality status, so this is per-output, not
|
|
225
|
+
per-LP-subsolve.
|
|
226
|
+
``outputs_complete`` — whether every required output was present,
|
|
227
|
+
non-empty and (if checked) fresh.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
succeeded: bool
|
|
231
|
+
reason: str
|
|
232
|
+
exit_code: int | None
|
|
233
|
+
per_solve: list[OutputCheck] = field(default_factory=list)
|
|
234
|
+
outputs_complete: bool = False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parquet_num_rows(path: Path) -> int | None:
|
|
238
|
+
"""Row count of a parquet file via footer metadata, or ``None``.
|
|
239
|
+
|
|
240
|
+
Reads only the parquet footer (no column data), so it is cheap even for
|
|
241
|
+
large tables. A missing/truncated/corrupt file (e.g. a partial write
|
|
242
|
+
from an interrupted run) returns ``None`` — the caller treats that as an
|
|
243
|
+
incomplete output, i.e. a failure signal, not a crash.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
import pyarrow.parquet as pq
|
|
247
|
+
|
|
248
|
+
return int(pq.ParquetFile(str(path)).metadata.num_rows)
|
|
249
|
+
except Exception as exc: # noqa: BLE001 - any read error ⇒ "not usable"
|
|
250
|
+
logger.debug("Could not read parquet row count for %s: %s", path, exc)
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _check_output(
|
|
255
|
+
output_dir: Path, filename: str, started_epoch: float | None,
|
|
256
|
+
) -> OutputCheck:
|
|
257
|
+
"""Assess a single required output file inside *output_dir*."""
|
|
258
|
+
path = output_dir / filename
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
return OutputCheck(
|
|
261
|
+
filename=filename,
|
|
262
|
+
present=False,
|
|
263
|
+
num_rows=None,
|
|
264
|
+
fresh=False,
|
|
265
|
+
detail="missing",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
num_rows = _parquet_num_rows(path)
|
|
269
|
+
if num_rows is None:
|
|
270
|
+
return OutputCheck(
|
|
271
|
+
filename=filename,
|
|
272
|
+
present=True,
|
|
273
|
+
num_rows=None,
|
|
274
|
+
fresh=False,
|
|
275
|
+
detail="present but unreadable/corrupt as parquet",
|
|
276
|
+
)
|
|
277
|
+
if num_rows == 0:
|
|
278
|
+
# An empty table fails on the row count regardless of freshness;
|
|
279
|
+
# report ``fresh`` honestly (mtime vs started_at) rather than
|
|
280
|
+
# conflating "empty" with "stale".
|
|
281
|
+
empty_fresh = True
|
|
282
|
+
if started_epoch is not None:
|
|
283
|
+
try:
|
|
284
|
+
empty_fresh = path.stat().st_mtime + 1.0 >= started_epoch
|
|
285
|
+
except OSError:
|
|
286
|
+
empty_fresh = False
|
|
287
|
+
return OutputCheck(
|
|
288
|
+
filename=filename,
|
|
289
|
+
present=True,
|
|
290
|
+
num_rows=0,
|
|
291
|
+
fresh=empty_fresh,
|
|
292
|
+
detail="present but empty (0 rows)",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
fresh = True
|
|
296
|
+
detail = "present, non-empty"
|
|
297
|
+
if started_epoch is not None:
|
|
298
|
+
try:
|
|
299
|
+
mtime = path.stat().st_mtime
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
fresh = False
|
|
302
|
+
detail = f"present, non-empty, but mtime unavailable ({exc})"
|
|
303
|
+
else:
|
|
304
|
+
# 1s slack absorbs coarse filesystem mtime granularity so a file
|
|
305
|
+
# written in the same second the subprocess launched is not
|
|
306
|
+
# wrongly judged stale.
|
|
307
|
+
if mtime + 1.0 < started_epoch:
|
|
308
|
+
fresh = False
|
|
309
|
+
detail = (
|
|
310
|
+
"present, non-empty, but STALE "
|
|
311
|
+
"(older than this run's start — left over from a "
|
|
312
|
+
"previous run)"
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
detail = "present, non-empty, fresh"
|
|
316
|
+
|
|
317
|
+
return OutputCheck(
|
|
318
|
+
filename=filename,
|
|
319
|
+
present=True,
|
|
320
|
+
num_rows=num_rows,
|
|
321
|
+
fresh=fresh,
|
|
322
|
+
detail=detail,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def assess_solve(
|
|
327
|
+
output_dir: Path | str,
|
|
328
|
+
*,
|
|
329
|
+
exit_code: int | None = None,
|
|
330
|
+
required_outputs: Sequence[str] | None = None,
|
|
331
|
+
started_at: float | int | datetime | None = None,
|
|
332
|
+
) -> SolveOutcome:
|
|
333
|
+
"""Decide whether a FlexTool solve run succeeded, from its outputs.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
output_dir:
|
|
338
|
+
The directory that directly holds the run's ``*.parquet`` result
|
|
339
|
+
files — i.e. ``<output_location>/output_parquet/<subdir>/``.
|
|
340
|
+
exit_code:
|
|
341
|
+
The subprocess exit code, if known. A *weak* input: it is a
|
|
342
|
+
warning that can be overridden (see the success rule below), never
|
|
343
|
+
the sole determinant. ``None`` means "not supplied".
|
|
344
|
+
required_outputs:
|
|
345
|
+
Output keys or ``*.parquet`` filenames that must be present and
|
|
346
|
+
non-empty for the run to count as successful. Defaults to the
|
|
347
|
+
calibrator's robust success gate, ``node_slack_up_d_e``
|
|
348
|
+
(:func:`default_required_outputs`); pass more if a caller wants
|
|
349
|
+
stricter completeness.
|
|
350
|
+
started_at:
|
|
351
|
+
POSIX timestamp (recommended: ``time.time()``) / :class:`datetime`
|
|
352
|
+
of when the subprocess was launched. A required output older than
|
|
353
|
+
this is treated as a stale leftover (not this run's product) and
|
|
354
|
+
fails completeness. **Required to make the nonzero-exit override
|
|
355
|
+
safe** — see the success rule. C1 must always pass it.
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
SolveOutcome
|
|
360
|
+
The verdict, its reason, the exit code echoed back, the per-output
|
|
361
|
+
evidence, and the ``outputs_complete`` flag.
|
|
362
|
+
|
|
363
|
+
Success rule
|
|
364
|
+
------------
|
|
365
|
+
Let ``complete`` = every required output present, a readable parquet
|
|
366
|
+
with ≥1 row, and (if ``started_at`` given) fresh.
|
|
367
|
+
|
|
368
|
+
* ``not complete`` → **failed**.
|
|
369
|
+
* ``complete`` and exit 0 / ``None`` → **succeeded** (``started_at``
|
|
370
|
+
optional: a successful run empties + rewrites the dir, so files are
|
|
371
|
+
fresh by construction).
|
|
372
|
+
* ``complete`` and nonzero exit and ``started_at`` given and fresh
|
|
373
|
+
→ **succeeded**; the nonzero exit is *overridden* (post-solve writer
|
|
374
|
+
crash with complete, fresh results).
|
|
375
|
+
* ``complete`` and nonzero exit and NO ``started_at``
|
|
376
|
+
→ **failed**; freshness is unverifiable, so a genuine failure that
|
|
377
|
+
left a prior run's outputs in place cannot be ruled out — the
|
|
378
|
+
override is refused.
|
|
379
|
+
"""
|
|
380
|
+
out_dir = Path(output_dir)
|
|
381
|
+
started_epoch = _as_epoch(started_at)
|
|
382
|
+
required = _normalise_required(required_outputs)
|
|
383
|
+
|
|
384
|
+
checks = [_check_output(out_dir, fn, started_epoch) for fn in required]
|
|
385
|
+
outputs_complete = bool(checks) and all(c.ok for c in checks)
|
|
386
|
+
|
|
387
|
+
if not out_dir.is_dir():
|
|
388
|
+
return SolveOutcome(
|
|
389
|
+
succeeded=False,
|
|
390
|
+
reason=(
|
|
391
|
+
f"output directory {out_dir} does not exist; the solve wrote "
|
|
392
|
+
"no results (a genuinely failed / infeasible / unaccepted "
|
|
393
|
+
"solve skips output writing entirely)."
|
|
394
|
+
),
|
|
395
|
+
exit_code=exit_code,
|
|
396
|
+
per_solve=checks,
|
|
397
|
+
outputs_complete=False,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if not outputs_complete:
|
|
401
|
+
bad = [c for c in checks if not c.ok]
|
|
402
|
+
detail = "; ".join(f"{c.filename}: {c.detail}" for c in bad)
|
|
403
|
+
return SolveOutcome(
|
|
404
|
+
succeeded=False,
|
|
405
|
+
reason=(
|
|
406
|
+
"required output(s) missing, empty or stale — the solve did "
|
|
407
|
+
f"not produce usable results [{detail}]. A genuinely failed "
|
|
408
|
+
"sub-solve is surfaced this way: FlexTool skips output "
|
|
409
|
+
"writing on a non-accepted cascade, so absent outputs ARE "
|
|
410
|
+
"the failure signal."
|
|
411
|
+
),
|
|
412
|
+
exit_code=exit_code,
|
|
413
|
+
per_solve=checks,
|
|
414
|
+
outputs_complete=False,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Every required output is present, non-empty and (subject to
|
|
418
|
+
# started_at) fresh. Because FlexTool writes outputs only for an
|
|
419
|
+
# accepted cascade, this state means no failed/unaccepted sub-solve is
|
|
420
|
+
# detectable.
|
|
421
|
+
#
|
|
422
|
+
# Exit 0 / None: succeed. On success write_outputs empties then
|
|
423
|
+
# rewrites the parquet dir, so the files are this run's product by
|
|
424
|
+
# construction — no stale-masking hole, and started_at is optional here.
|
|
425
|
+
if exit_code in (None, 0):
|
|
426
|
+
fresh_note = (
|
|
427
|
+
" (verified fresh against this run's start time)"
|
|
428
|
+
if started_epoch is not None
|
|
429
|
+
else ""
|
|
430
|
+
)
|
|
431
|
+
return SolveOutcome(
|
|
432
|
+
succeeded=True,
|
|
433
|
+
reason=(
|
|
434
|
+
"all required outputs present and non-empty"
|
|
435
|
+
f"{fresh_note}; "
|
|
436
|
+
+ ("exit code 0." if exit_code == 0 else "no exit code "
|
|
437
|
+
"supplied.")
|
|
438
|
+
),
|
|
439
|
+
exit_code=exit_code,
|
|
440
|
+
per_solve=checks,
|
|
441
|
+
outputs_complete=True,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Nonzero exit but complete outputs. This is EITHER the known
|
|
445
|
+
# post-solve writer-crash case (the cascade solved and wrote complete
|
|
446
|
+
# results; the nonzero exit is a writer failure) OR a genuinely failed
|
|
447
|
+
# solve that skipped write_outputs — which does NOT empty the parquet
|
|
448
|
+
# dir — leaving a PRIOR iteration's complete outputs lingering. The
|
|
449
|
+
# only thing that tells these apart is freshness. So the override to
|
|
450
|
+
# success is allowed ONLY when started_at was supplied AND every output
|
|
451
|
+
# verified fresh; otherwise we must NOT override.
|
|
452
|
+
if started_epoch is None:
|
|
453
|
+
return SolveOutcome(
|
|
454
|
+
succeeded=False,
|
|
455
|
+
reason=(
|
|
456
|
+
f"nonzero exit code {exit_code} and freshness is unverifiable "
|
|
457
|
+
"(no started_at supplied) — the required outputs are complete "
|
|
458
|
+
"but we CANNOT distinguish a post-solve writer crash (which "
|
|
459
|
+
"leaves this run's fresh results) from a genuinely failed "
|
|
460
|
+
"solve that skipped output writing and left a PRIOR run's "
|
|
461
|
+
"outputs in place. Pass started_at (the subprocess launch "
|
|
462
|
+
"time) so the override can be made safely."
|
|
463
|
+
),
|
|
464
|
+
exit_code=exit_code,
|
|
465
|
+
per_solve=checks,
|
|
466
|
+
outputs_complete=True,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# started_at supplied and all outputs are fresh → safe to override the
|
|
470
|
+
# nonzero exit to success (the post-solve writer-crash case, e.g. the
|
|
471
|
+
# PLEXOS→FlexTool writer's "Shared-alternative write failed" KeyError,
|
|
472
|
+
# which lives outside this engine).
|
|
473
|
+
return SolveOutcome(
|
|
474
|
+
succeeded=True,
|
|
475
|
+
reason=(
|
|
476
|
+
"all required outputs present, non-empty and verified fresh "
|
|
477
|
+
f"against this run's start time; the nonzero exit code {exit_code} "
|
|
478
|
+
"is OVERRIDDEN to success — the cascade solved and wrote complete, "
|
|
479
|
+
"fresh results, and the nonzero exit reflects a post-solve writer "
|
|
480
|
+
"failure (e.g. the model-specific 'Shared-alternative write "
|
|
481
|
+
"failed' KeyError in the PLEXOS→FlexTool writer, which is outside "
|
|
482
|
+
"the solve engine), not a solve failure."
|
|
483
|
+
),
|
|
484
|
+
exit_code=exit_code,
|
|
485
|
+
per_solve=checks,
|
|
486
|
+
outputs_complete=True,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
__all__ = [
|
|
491
|
+
"OutputCheck",
|
|
492
|
+
"SolveOutcome",
|
|
493
|
+
"assess_solve",
|
|
494
|
+
"default_required_outputs",
|
|
495
|
+
]
|
flextool/cli/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""CLI entry points for FlexTool. Available commands:
|
|
2
|
+
- flextool / run_flextool: Run model optimization for a scenario
|
|
3
|
+
- write_outputs: Process and write solver outputs
|
|
4
|
+
- scenario_results: Cross-scenario comparison analysis
|
|
5
|
+
- read_tabular_input: Import CSV/Excel data to Spine database
|
|
6
|
+
- execute_flextool_workflow: Full workflow orchestration
|
|
7
|
+
- update_flextool: Update FlexTool from GitHub
|
|
8
|
+
- migrate_database: Migrate database to latest schema
|
|
9
|
+
"""
|
flextool/cli/_console.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared ``__main__`` shim for FlexTool CLI Tools.
|
|
2
|
+
|
|
3
|
+
Spine Toolbox's *Basic Console* does not run a Python Tool as a separate
|
|
4
|
+
``python script.py`` process. It exec's the Tool's file inside a persistent
|
|
5
|
+
``python -i`` REPL (``sys.flags.interactive`` is set). A ``sys.exit()``
|
|
6
|
+
there — whether at the end of the program or raised deep inside ``main()`` —
|
|
7
|
+
raises ``SystemExit``, which TERMINATES that REPL; Toolbox then pings the dead
|
|
8
|
+
process and reports a spurious ``Kernel died (×_×)``. The Basic Console
|
|
9
|
+
decides success/failure from an *uncaught exception*, not the process exit
|
|
10
|
+
code.
|
|
11
|
+
|
|
12
|
+
``run_tool`` reconciles both runtimes. It invokes the Tool's entry point and
|
|
13
|
+
swallows the resulting ``SystemExit``:
|
|
14
|
+
|
|
15
|
+
* Under ``-i`` (Basic Console) it returns quietly on a zero/``None`` code and
|
|
16
|
+
re-raises a ``RuntimeError`` on a non-zero one, so Toolbox marks the Tool
|
|
17
|
+
failed while the REPL stays alive — no "Kernel died".
|
|
18
|
+
* As a standalone CLI (``sys.flags.interactive == 0``) it preserves normal
|
|
19
|
+
shell exit-code semantics (re-raises the original ``SystemExit`` / exits with
|
|
20
|
+
the entry point's return value).
|
|
21
|
+
|
|
22
|
+
Use it at a Tool's ``__main__`` boundary::
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
run_tool(main)
|
|
26
|
+
"""
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_tool(entry):
|
|
31
|
+
"""Run ``entry`` (a zero-arg callable) reconciling CLI vs Basic Console.
|
|
32
|
+
|
|
33
|
+
See the module docstring for the rationale.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
rc = entry()
|
|
37
|
+
except SystemExit as exc:
|
|
38
|
+
if not sys.flags.interactive:
|
|
39
|
+
raise # standalone CLI: let the original exit code propagate
|
|
40
|
+
code = exc.code
|
|
41
|
+
rc = 0 if code is None else code
|
|
42
|
+
if sys.flags.interactive:
|
|
43
|
+
# Basic Console: NEVER sys.exit() — it would kill the persistent REPL.
|
|
44
|
+
# Signal failure via an exception (Toolbox catches it); succeed quietly.
|
|
45
|
+
if isinstance(rc, int):
|
|
46
|
+
if rc != 0:
|
|
47
|
+
raise RuntimeError(f"FlexTool Tool failed (exit code {rc}).")
|
|
48
|
+
elif rc is not None: # e.g. sys.exit("some message")
|
|
49
|
+
raise RuntimeError(f"FlexTool Tool failed: {rc}")
|
|
50
|
+
return rc
|
|
51
|
+
sys.exit(rc)
|