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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
flextool/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """FlexTool energy system optimization model package."""
2
+ import os as _os
3
+ # jemalloc decay config — set BEFORE the first ``import polars`` (which
4
+ # happens transitively in the imports just below). polars statically
5
+ # links jemalloc; jemalloc reads ``_RJEM_MALLOC_CONF`` exactly once at
6
+ # .so load. Its default ``dirty_decay_ms`` (~10 s) is longer than the
7
+ # gap between rolling-solve rolls, so each roll's freed GB-scale
8
+ # coefficient frames stay resident as dirty pages when the next roll
9
+ # starts allocating — the per-roll ``priv_dirty`` floor ratchets up
10
+ # (~+2 GB/roll on the 9-roll DES run). ``dirty_decay_ms:1000`` purges a
11
+ # roll's freed pages ~1 s after they go idle (well before the next roll,
12
+ # which takes minutes) without eagerly madvise-ing on every transient
13
+ # free inside a hot operation. ``muzzy_decay_ms:0`` forces MADV_DONTNEED
14
+ # (so Private_Dirty actually drops) rather than leaving pages as
15
+ # reclaimable-but-resident MADV_FREE. glibc knobs (MALLOC_ARENA_MAX,
16
+ # malloc_trim) do NOT touch polars memory — this is the lever that does.
17
+ # ``setdefault`` so a shell-provided value still wins (A/B profiling).
18
+ _os.environ.setdefault("_RJEM_MALLOC_CONF", "dirty_decay_ms:1000,muzzy_decay_ms:0")
19
+ import warnings as _warnings
20
+ # Silence requests' RequestsDependencyWarning. The FlexTool venv ships a
21
+ # ``requests`` whose pinned urllib3/charset_normalizer version ranges lag the
22
+ # (working) installed versions, so ``import requests`` warns once at import
23
+ # time. It is harmless but leaks into Spine Toolbox's Tool console for any
24
+ # path that pulls in requests (e.g. an ``http://`` Spine DB-server URL).
25
+ # Match by message — set BEFORE requests is imported transitively below — so
26
+ # we needn't import requests here just to reference its warning class, and the
27
+ # filter stays robust across the venv's exact version numbers.
28
+ _warnings.filterwarnings(
29
+ "ignore",
30
+ message=r"urllib3 .*doesn't match a supported version",
31
+ )
32
+ __all__ = [
33
+ 'write_outputs',
34
+ 'migrate_database',
35
+ 'initialize_database',
36
+ 'update_flextool',
37
+ ]
38
+ from flextool.process_outputs import write_outputs # noqa: E402 (after env/warning config above)
39
+ from flextool.update_flextool import migrate_database, initialize_database, update_flextool # noqa: E402
40
+
41
+ name = "flextool"
@@ -0,0 +1,193 @@
1
+ """In-process memory sampler — timestamped, allocator-aware memory trace.
2
+
3
+ RSS is a poor signal for this workload: it conflates shared, clean, and
4
+ allocator-reserved-but-free pages with genuinely-live private data. This
5
+ sampler additionally records, per sample:
6
+
7
+ * ``pss_gb`` / ``priv_dirty_gb`` / ``proc_swap_gb`` — from
8
+ ``/proc/self/smaps_rollup``. ``Private_Dirty + Swap`` is the
9
+ allocator-agnostic "what this process truly owns" number (counts dirty
10
+ anon pages whether they live in glibc, mimalloc, or the C++ heap, and
11
+ whether resident or swapped).
12
+ * ``glibc_inuse_gb`` / ``glibc_free_gb`` / ``glibc_mmap_gb`` — from
13
+ glibc ``mallinfo2()`` (``uordblks`` / ``fordblks`` / ``hblkhd``). The
14
+ *live-vs-reserved* discriminator: ``fordblks`` is memory glibc has
15
+ freed but is holding on its arena free-list rather than returning to
16
+ the OS (what ``malloc_trim`` would release). NOTE: only sees the glibc
17
+ allocator — HiGHS/C++ go through glibc, but polars (Rust) may use its
18
+ own allocator, so a large ``priv_dirty`` with small ``glibc_inuse``
19
+ points at the non-glibc (polars) side.
20
+ * ``mem_used_gb`` — system used (``MemTotal - MemAvailable``), the number
21
+ most desktop system monitors plot. ``available_gb`` / ``swap_used_gb``
22
+ kept for back-compat.
23
+
24
+ Gated by ``FLEXTOOL_MEM_SAMPLER``; interval via
25
+ ``FLEXTOOL_MEM_SAMPLER_INTERVAL_MS`` (default 100 ms).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import ctypes
30
+ import datetime
31
+ import os
32
+ import sys
33
+ import threading
34
+ import time
35
+
36
+
37
+ class _Mallinfo2(ctypes.Structure):
38
+ # glibc >= 2.33. All fields size_t (the legacy ``mallinfo`` used int
39
+ # and overflows past 2 GB — useless here).
40
+ _fields_ = [
41
+ (n, ctypes.c_size_t)
42
+ for n in (
43
+ "arena", "ordblks", "smblks", "hblks", "hblkhd", "usmblks",
44
+ "fsmblks", "uordblks", "fordblks", "keepcost",
45
+ )
46
+ ]
47
+
48
+
49
+ def _make_mallinfo2():
50
+ """Return a callable giving (inuse, free, mmap) bytes, or None."""
51
+ try:
52
+ libc = ctypes.CDLL("libc.so.6")
53
+ fn = libc.mallinfo2 # AttributeError on glibc < 2.33
54
+ fn.restype = _Mallinfo2
55
+ fn.argtypes = []
56
+ except (OSError, AttributeError):
57
+ return None
58
+
59
+ def _read():
60
+ mi = fn()
61
+ return mi.uordblks, mi.fordblks, mi.hblkhd
62
+
63
+ return _read
64
+
65
+
66
+ def start_mem_sampler() -> None:
67
+ """Start a daemon thread that samples memory at a fixed interval if FLEXTOOL_MEM_SAMPLER is set."""
68
+ if os.environ.get("FLEXTOOL_MEM_SAMPLER", "").strip().lower() in ("", "0", "false"):
69
+ return
70
+
71
+ pid = os.getpid()
72
+ log_path = os.environ.get("FLEXTOOL_MEM_SAMPLER_LOG", f"/tmp/flextool_mem_sampler_{pid}.log")
73
+ try:
74
+ interval_ms = max(20, min(10000, int(os.environ.get("FLEXTOOL_MEM_SAMPLER_INTERVAL_MS", "100"))))
75
+ except ValueError:
76
+ interval_ms = 100
77
+ interval_s = interval_ms / 1000.0
78
+
79
+ if not sys.platform.startswith("linux"):
80
+ with open(log_path, "a", buffering=1) as _f:
81
+ _f.write(
82
+ f"# flextool memory sampler pid={pid} WARNING: platform is not "
83
+ f"linux — sampler disabled\n"
84
+ )
85
+ return
86
+
87
+ _GB = float(2 ** 30)
88
+ _mallinfo2 = _make_mallinfo2()
89
+
90
+ def _read_kv_bytes(path: str, keys: tuple[str, ...]) -> dict[str, int]:
91
+ """Parse ``Key: N kB`` lines from a /proc file → bytes."""
92
+ out: dict[str, int] = {}
93
+ want = set(keys)
94
+ with open(path) as fh:
95
+ for line in fh:
96
+ k = line.split(":", 1)[0]
97
+ if k in want:
98
+ out[k] = int(line.split()[1]) * 1024
99
+ if len(out) == len(want):
100
+ break
101
+ return out
102
+
103
+ def _sample() -> dict[str, float]:
104
+ # Per-process private cost — allocator-agnostic.
105
+ rss = pss = priv_dirty = proc_swap = 0
106
+ try:
107
+ r = _read_kv_bytes(
108
+ "/proc/self/smaps_rollup",
109
+ ("Rss", "Pss", "Private_Dirty", "Swap"),
110
+ )
111
+ rss = r.get("Rss", 0)
112
+ pss = r.get("Pss", 0)
113
+ priv_dirty = r.get("Private_Dirty", 0)
114
+ proc_swap = r.get("Swap", 0)
115
+ except Exception:
116
+ pass
117
+ if rss == 0:
118
+ # smaps_rollup unavailable — fall back to VmRSS.
119
+ try:
120
+ rss = _read_kv_bytes("/proc/self/status", ("VmRSS",)).get("VmRSS", 0)
121
+ except Exception:
122
+ pass
123
+
124
+ # System-wide.
125
+ mem_total = mem_avail = swap_total = swap_free = 0
126
+ try:
127
+ m = _read_kv_bytes(
128
+ "/proc/meminfo",
129
+ ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree"),
130
+ )
131
+ mem_total = m.get("MemTotal", 0)
132
+ mem_avail = m.get("MemAvailable", 0)
133
+ swap_total = m.get("SwapTotal", 0)
134
+ swap_free = m.get("SwapFree", 0)
135
+ except Exception:
136
+ pass
137
+
138
+ # glibc allocator breakdown — live vs reserved-free.
139
+ g_inuse = g_free = g_mmap = -1
140
+ if _mallinfo2 is not None:
141
+ try:
142
+ g_inuse, g_free, g_mmap = _mallinfo2()
143
+ except Exception:
144
+ pass
145
+
146
+ return {
147
+ "rss_gb": rss / _GB,
148
+ "pss_gb": pss / _GB,
149
+ "priv_dirty_gb": priv_dirty / _GB,
150
+ "proc_swap_gb": proc_swap / _GB,
151
+ "available_gb": mem_avail / _GB,
152
+ "mem_used_gb": (mem_total - mem_avail) / _GB,
153
+ "swap_used_gb": (swap_total - swap_free) / _GB,
154
+ "glibc_inuse_gb": g_inuse / _GB if g_inuse >= 0 else -1.0,
155
+ "glibc_free_gb": g_free / _GB if g_free >= 0 else -1.0,
156
+ "glibc_mmap_gb": g_mmap / _GB if g_mmap >= 0 else -1.0,
157
+ }
158
+
159
+ started_at = datetime.datetime.now(datetime.UTC).isoformat()
160
+ # Stable column order so post-hoc parsing is trivial.
161
+ _cols = (
162
+ "rss_gb", "pss_gb", "priv_dirty_gb", "proc_swap_gb",
163
+ "available_gb", "mem_used_gb", "swap_used_gb",
164
+ "glibc_inuse_gb", "glibc_free_gb", "glibc_mmap_gb",
165
+ )
166
+
167
+ def _loop(log_path: str, interval_s: float) -> None:
168
+ mono_start = time.monotonic()
169
+ with open(log_path, "a", buffering=1) as f:
170
+ f.write(
171
+ f"# flextool memory sampler pid={pid} started_at={started_at} "
172
+ f"interval_ms={interval_ms} mallinfo2={'yes' if _mallinfo2 else 'no'}\n"
173
+ )
174
+ f.write("# priv_dirty_gb+proc_swap_gb = true private cost; "
175
+ "glibc_free_gb = held-free (malloc_trim-able); "
176
+ "mem_used_gb = system used (monitor metric)\n")
177
+ while True:
178
+ ts = datetime.datetime.now(datetime.UTC).isoformat()
179
+ mono_s = time.monotonic() - mono_start
180
+ try:
181
+ s = _sample()
182
+ parts = "\t".join(f"{c}={s[c]:.4f}" for c in _cols)
183
+ line = f"ts={ts}\tmono_s={mono_s:.3f}\tepoch={time.time():.3f}\t{parts}\n"
184
+ except Exception as exc:
185
+ line = f"ts={ts}\tmono_s={mono_s:.3f}\tepoch={time.time():.3f}\terror={repr(exc)}\n"
186
+ f.write(line)
187
+ # Explicit flush so the last samples before SIGKILL reach disk;
188
+ # line-buffered mode alone won't flush until the process exits cleanly.
189
+ f.flush()
190
+ time.sleep(interval_s)
191
+
192
+ t = threading.Thread(target=_loop, args=(log_path, interval_s), name="flextool-mem-sampler", daemon=True)
193
+ t.start()
flextool/_resources.py ADDED
@@ -0,0 +1,43 @@
1
+ """Locate bundled FlexTool package data files.
2
+
3
+ Static FlexTool resources — schema JSONs, YAML/text templates, and
4
+ canonical-database sources (all under ``flextool/schemas/``), plus the HiGHS
5
+ options template (``flextool/bin/``) — must be reached through
6
+ :mod:`importlib.resources` rather than ``Path(__file__).resolve()``
7
+ walks. After ``pip install flextool`` the package is the only thing
8
+ on ``sys.path``; the historical layout where these dirs sat at the
9
+ *repo root* alongside the package no longer exists.
10
+
11
+ Two helpers are exposed:
12
+
13
+ ``package_data_path(relative)``
14
+ Returns a :class:`pathlib.Path` to a resource inside the
15
+ ``flextool`` package, e.g.
16
+ ``package_data_path("schemas/default_plots.yaml")``.
17
+ Editable installs return the real on-disk path; wheel installs
18
+ return the unpacked site-packages path (importlib.resources
19
+ materialises zipped data on demand, but we never ship zipped so
20
+ in practice this is always a stable filesystem path).
21
+
22
+ ``package_data_text(relative)``
23
+ Convenience wrapper that returns the file contents as a string —
24
+ handy when the caller doesn't actually need a path (YAML/JSON
25
+ parsing).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from importlib import resources
31
+ from pathlib import Path
32
+
33
+ import flextool
34
+
35
+
36
+ def package_data_path(relative: str) -> Path:
37
+ """Path to ``flextool/<relative>`` inside the installed package."""
38
+ return Path(resources.files(flextool).joinpath(relative))
39
+
40
+
41
+ def package_data_text(relative: str) -> str:
42
+ """Text contents of ``flextool/<relative>``."""
43
+ return resources.files(flextool).joinpath(relative).read_text(encoding="utf-8")
@@ -0,0 +1,51 @@
1
+ """FlexTool energy-margin calibrator package.
2
+
3
+ The calibrator drives an investment+dispatch solve each iteration by
4
+ shelling out to :mod:`flextool.cli.cmd_run_flextool`, then reads the
5
+ per-node unserved-energy slack from the produced outputs to decide how to
6
+ adjust the energy margin. This package hosts that loop (the ``C1`` CLI,
7
+ ``python -m flextool.calibrate``, lands here) and its building blocks.
8
+
9
+ The first building block is the resilient solve-success detector in
10
+ :mod:`flextool.calibrate._solve_status` — it decides "did this solve
11
+ actually succeed?" from the solve's *output directory* plus the (weak)
12
+ subprocess exit code, tolerating a known model-specific post-solve writer
13
+ crash that can bubble to a nonzero exit even when every output is present.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from flextool.calibrate._final_outputs import (
19
+ FINAL_WRITE_METHOD_CHOICES,
20
+ write_final_outputs,
21
+ )
22
+ from flextool.calibrate._loop import (
23
+ CalibConfig,
24
+ CalibError,
25
+ CalibResult,
26
+ IterRecord,
27
+ run_calibration,
28
+ )
29
+ from flextool.calibrate._report import format_summary, write_report
30
+ from flextool.calibrate._solve_status import (
31
+ OutputCheck,
32
+ SolveOutcome,
33
+ assess_solve,
34
+ default_required_outputs,
35
+ )
36
+
37
+ __all__ = [
38
+ "FINAL_WRITE_METHOD_CHOICES",
39
+ "CalibConfig",
40
+ "CalibError",
41
+ "CalibResult",
42
+ "IterRecord",
43
+ "OutputCheck",
44
+ "SolveOutcome",
45
+ "assess_solve",
46
+ "default_required_outputs",
47
+ "format_summary",
48
+ "run_calibration",
49
+ "write_final_outputs",
50
+ "write_report",
51
+ ]
@@ -0,0 +1,11 @@
1
+ """``python -m flextool.calibrate`` entry point.
2
+
3
+ Delegates to :func:`flextool.calibrate._cli.main` so the calibrator can be
4
+ launched as a module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from flextool.calibrate._cli import main
10
+
11
+ raise SystemExit(main())
@@ -0,0 +1,316 @@
1
+ """Command-line front end for the adequacy-margin calibrator (C2).
2
+
3
+ Wires the argument surface onto :class:`~flextool.calibrate._loop.CalibConfig`,
4
+ runs :func:`~flextool.calibrate._loop.run_calibration`, then renders the
5
+ report CSVs and prints the human-readable summary. Exposed as
6
+ ``python -m flextool.calibrate`` via :mod:`flextool.calibrate.__main__`.
7
+
8
+ The calibrator solves ``--iterations + 1`` times (iteration 0 is the
9
+ baseline) and, for every shedding node, sizes an ``energy_margin_adder``
10
+ increment that injects that node's residual unserved energy back as demand,
11
+ damped and guarded against over-build. This module is the operator's entry
12
+ point to that loop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from flextool.calibrate._loop import (
22
+ CalibConfig,
23
+ CalibError,
24
+ CalibResult,
25
+ run_calibration,
26
+ )
27
+ from flextool.calibrate._final_outputs import (
28
+ FINAL_WRITE_METHOD_CHOICES,
29
+ write_final_outputs,
30
+ )
31
+ from flextool.calibrate._report import format_summary, write_report
32
+
33
+ DEFAULT_WORK_DIR = Path("calib_work")
34
+ DEFAULT_OUT_ROOT = Path("calib_out")
35
+ # Small positive default: convergence when total residual unserved energy is
36
+ # at/below this many MWh (a single-MWh floor absorbs solver noise).
37
+ DEFAULT_SLACK_THRESHOLD_MWH = 1.0
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ """Build the argument parser for the calibrator CLI."""
42
+ parser = argparse.ArgumentParser(
43
+ prog="flextool.calibrate",
44
+ description=(
45
+ "Calibrate per-node energy-margin adders until every node's "
46
+ "residual unserved energy falls to/under the slack threshold, "
47
+ "freezing resource-capped nodes rather than over-building demand."
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "db",
52
+ help="SpineDB URL (or path) of the model to calibrate.",
53
+ )
54
+ parser.add_argument(
55
+ "scenario",
56
+ help="Name of the scenario (model instance) to calibrate.",
57
+ )
58
+ parser.add_argument(
59
+ "--iterations",
60
+ type=int,
61
+ required=True,
62
+ help=(
63
+ "Number of ADJUSTMENT iterations after the baseline; the loop "
64
+ "runs iterations+1 solves (iteration 0 is the baseline)."
65
+ ),
66
+ )
67
+ parser.add_argument(
68
+ "--slack-threshold",
69
+ type=float,
70
+ default=DEFAULT_SLACK_THRESHOLD_MWH,
71
+ dest="slack_threshold_mwh",
72
+ help=(
73
+ "Convergence threshold in MWh: stop once total residual unserved "
74
+ "energy is at/under this value (default: "
75
+ f"{DEFAULT_SLACK_THRESHOLD_MWH})."
76
+ ),
77
+ )
78
+ parser.add_argument(
79
+ "--stall-fraction",
80
+ type=float,
81
+ default=0.05,
82
+ dest="stall_fraction",
83
+ help=(
84
+ "Over-build guard STALL fraction [0..1]. A shedding node whose "
85
+ "residual unserved energy drops by LESS than this fraction of its "
86
+ "prior gap in response to its own bump is frozen as resource-capped "
87
+ "(margin buys it no adequacy) and reported as needing firm "
88
+ "capacity / imports / storage. HIGHER freezes a stalled node "
89
+ "SOONER; 0.0 disables the guard. Default: 0.05."
90
+ ),
91
+ )
92
+ parser.add_argument(
93
+ "--over-build-tightness",
94
+ type=float,
95
+ default=0.05,
96
+ dest="over_build_tightness",
97
+ help=(
98
+ "DEPRECATED / no-op: retained for compatibility only. The "
99
+ "over-build guard now freezes on residual STALL (see "
100
+ "--stall-fraction), not curtailment efficiency, so this value is "
101
+ "not consulted. Default: 0.05."
102
+ ),
103
+ )
104
+ parser.add_argument(
105
+ "--damping-first-iteration",
106
+ type=float,
107
+ default=1.0,
108
+ dest="damping_first",
109
+ help=(
110
+ "Damping factor lambda applied to the FIRST correction "
111
+ "(increment = lambda * residual / W). 1.0 = full undamped step. "
112
+ "Default: 1.0."
113
+ ),
114
+ )
115
+ parser.add_argument(
116
+ "--damping-remaining-iterations",
117
+ type=float,
118
+ default=0.5,
119
+ dest="damping_remaining",
120
+ help=(
121
+ "Damping factor lambda applied to every correction AFTER the "
122
+ "first; lower damps oscillation as slack nears zero. Default: 0.5."
123
+ ),
124
+ )
125
+ parser.add_argument(
126
+ "--overshoot",
127
+ type=float,
128
+ default=1.0,
129
+ dest="overshoot",
130
+ help=(
131
+ "Planning-margin SAFETY multiplier on the sized margin (default "
132
+ "1.0 = off). A single-year (or single-year representative-period) "
133
+ "model under-estimates true multi-year severity, so a value >1 "
134
+ "deliberately over-provisions: overshoot=1.2 builds ~20%% beyond "
135
+ "the measured slack. Higher builds more headroom for unmodeled "
136
+ "multi-year risk; the right value is MODEL-DEPENDENT."
137
+ ),
138
+ )
139
+ parser.add_argument(
140
+ "--sizing",
141
+ choices=("uniform", "timed"),
142
+ default="uniform",
143
+ dest="sizing",
144
+ help=(
145
+ "Adder placement mode. 'uniform' (default): a constant "
146
+ "per-timestep margin sized lambda*residual/W. 'timed': the SAME "
147
+ "total energy placed per-cell at the low-VRE stress hours, folded "
148
+ "from node_slack_up_dt_e onto the representative timeline."
149
+ ),
150
+ )
151
+ parser.add_argument(
152
+ "--warm-start-cache-dir",
153
+ type=Path,
154
+ default=None,
155
+ dest="warm_start_cache_dir",
156
+ help=(
157
+ "Directory for the warm-start basis cache shared across "
158
+ "iterations (default: <work-dir>/warm_start_cache)."
159
+ ),
160
+ )
161
+ parser.add_argument(
162
+ "--work-dir",
163
+ type=Path,
164
+ default=DEFAULT_WORK_DIR,
165
+ dest="work_dir",
166
+ help=(
167
+ "Subprocess working directory for the per-iteration solves "
168
+ f"(default: {DEFAULT_WORK_DIR}/)."
169
+ ),
170
+ )
171
+ parser.add_argument(
172
+ "--output-location",
173
+ type=Path,
174
+ default=DEFAULT_OUT_ROOT,
175
+ dest="out_root",
176
+ help=(
177
+ "Output-location root; solve outputs land under "
178
+ "<output-location>/output_parquet/<scenario>/ and the report "
179
+ "CSVs under <output-location>/calibration_reports/<scenario>/ "
180
+ f"(default: {DEFAULT_OUT_ROOT}/)."
181
+ ),
182
+ )
183
+ parser.add_argument(
184
+ "--final-write-methods",
185
+ type=str,
186
+ nargs="+",
187
+ default=["csv"],
188
+ choices=list(FINAL_WRITE_METHOD_CHOICES),
189
+ dest="final_write_methods",
190
+ help=(
191
+ "After the loop, regenerate these output formats from the final "
192
+ "surviving parquet (<output-location>/output_parquet/<scenario>/) "
193
+ "WITHOUT re-solving — so the calibrated model's results are "
194
+ "available just like a regular run's. parquet is always present, "
195
+ "so it is not a choice here; the rest derive from it. "
196
+ "Default: csv. Use --skip-final-outputs to leave the results "
197
+ "parquet-only."
198
+ ),
199
+ )
200
+ parser.add_argument(
201
+ "--skip-final-outputs",
202
+ action="store_true",
203
+ dest="skip_final_outputs",
204
+ help=(
205
+ "Do not regenerate any non-parquet outputs after the loop; leave "
206
+ "the results as the parquet tree written by the final solve."
207
+ ),
208
+ )
209
+ parser.add_argument(
210
+ "--results-db-url",
211
+ type=str,
212
+ default=None,
213
+ dest="results_db_url",
214
+ help=(
215
+ "Target SpineDB URL for the 'spinedb' final-write-method (default: "
216
+ "<output-location>/results.sqlite). Only consulted when 'spinedb' "
217
+ "is in --final-write-methods."
218
+ ),
219
+ )
220
+ parser.add_argument(
221
+ "--debug",
222
+ action="store_true",
223
+ help=(
224
+ "Archive each iteration's outputs to out_iter_<k>/ instead of "
225
+ "clearing them between iterations."
226
+ ),
227
+ )
228
+ return parser
229
+
230
+
231
+ def _config_from_args(args: argparse.Namespace) -> CalibConfig:
232
+ """Translate parsed args into a :class:`CalibConfig`.
233
+
234
+ Fills the warm-start cache default relative to the work dir when the
235
+ operator did not pin one.
236
+ """
237
+ warm_start_cache_dir = args.warm_start_cache_dir
238
+ if warm_start_cache_dir is None:
239
+ warm_start_cache_dir = Path(args.work_dir) / "warm_start_cache"
240
+ # --skip-final-outputs wins over any requested formats.
241
+ final_write_methods = (
242
+ () if args.skip_final_outputs else tuple(args.final_write_methods)
243
+ )
244
+ return CalibConfig(
245
+ iterations=args.iterations,
246
+ slack_threshold_mwh=args.slack_threshold_mwh,
247
+ damping_first=args.damping_first,
248
+ damping_remaining=args.damping_remaining,
249
+ over_build_tightness=args.over_build_tightness,
250
+ warm_start_cache_dir=Path(warm_start_cache_dir),
251
+ work_dir=Path(args.work_dir),
252
+ out_root=Path(args.out_root),
253
+ debug=args.debug,
254
+ sizing=args.sizing,
255
+ overshoot=args.overshoot,
256
+ stall_fraction=args.stall_fraction,
257
+ final_write_methods=final_write_methods,
258
+ )
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ """Run the calibrator from the command line.
263
+
264
+ Parses *argv* (or ``sys.argv``), runs the calibration, writes the report
265
+ CSVs under ``<output-location>/calibration_reports/<scenario>/``, prints the
266
+ summary to stdout, and (unless ``--skip-final-outputs``) regenerates the
267
+ requested non-parquet output formats from the final surviving parquet
268
+ without re-solving. Returns 0 on success; on a fail-closed solve
269
+ (:class:`CalibError`) prints the reason to stderr and returns 1.
270
+ """
271
+ parser = build_parser()
272
+ args = parser.parse_args(argv)
273
+ config = _config_from_args(args)
274
+
275
+ try:
276
+ result: CalibResult = run_calibration(args.db, args.scenario, config)
277
+ except CalibError as exc:
278
+ print(f"calibration failed: {exc}", file=sys.stderr)
279
+ return 1
280
+
281
+ # Namespace the report CSVs by scenario (matching output_parquet/<scenario>/)
282
+ # so calibrating several scenarios into one shared output-location root does
283
+ # not overwrite one scenario's report with the next.
284
+ report_dir = Path(config.out_root) / "calibration_reports" / args.scenario
285
+ write_report(result, out_dir=report_dir)
286
+ print(format_summary(result))
287
+ print(f"\nReport CSVs written under {report_dir}/")
288
+
289
+ # Regenerate the requested regular output formats from the final surviving
290
+ # parquet (no re-solve). A failure here does NOT fail the calibration — the
291
+ # solve succeeded and its results are intact as parquet, so this is a
292
+ # convenience step; warn and still return 0.
293
+ if config.final_write_methods:
294
+ try:
295
+ written = write_final_outputs(
296
+ config.out_root,
297
+ args.scenario,
298
+ config.final_write_methods,
299
+ results_db_url=args.results_db_url,
300
+ )
301
+ print(
302
+ f"Final outputs ({', '.join(written)}) regenerated from parquet "
303
+ f"under {config.out_root}/"
304
+ )
305
+ except Exception as exc: # noqa: BLE001 — never fail the run on output regen
306
+ print(
307
+ f"warning: could not regenerate final outputs "
308
+ f"({', '.join(config.final_write_methods)}) from parquet: {exc}. "
309
+ f"The parquet results under {config.out_root}/output_parquet/"
310
+ f"{args.scenario}/ are intact.",
311
+ file=sys.stderr,
312
+ )
313
+ return 0
314
+
315
+
316
+ __all__ = ["build_parser", "main"]