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
@@ -0,0 +1,723 @@
1
+ """Phase F — typed registry of FlexTool output bundle files + manifest.
2
+
3
+ This module sits *atop* the existing output writers; it does NOT change
4
+ who writes what. It provides:
5
+
6
+ * :class:`ParquetSpec` — a typed schema describing one emitted output.
7
+ * :data:`REGISTRY` — a ``key → ParquetSpec`` dict covering both
8
+ ``output_raw/`` (per-solve LP variable parquets, plus 4 capacity CSVs
9
+ carried by ``handoff_writers``) and ``output_processed/`` (the
10
+ ``write_outputs`` per-table outputs governed by ``--write-methods``;
11
+ ``output_processed`` here is the logical category covering
12
+ ``output_parquet/``, ``output_csv/``, ``output_excel/`` and
13
+ ``output_plots/`` produced by :mod:`flextool.process_outputs.write_outputs`).
14
+ * :func:`write_parquet` — typed write path. Validates a frame's column
15
+ set against its REGISTRY entry, then writes via polars
16
+ ``DataFrame.write_parquet`` to ``work_folder / category / filename``.
17
+ Existing writers can opt in by calling this; the existing writers are
18
+ NOT migrated in this phase.
19
+ * :func:`write_manifest` — walks REGISTRY, checks file presence on disk,
20
+ emits ``manifest.json`` at ``work_folder / "output_raw" / "manifest.json"``.
21
+ Idempotent.
22
+ Logs warnings (not errors) for mismatches: a registered file that
23
+ doesn't exist (and isn't documented as conditional), or a parquet
24
+ file present in ``output_raw/`` / ``output_processed/`` with no
25
+ matching REGISTRY entry.
26
+
27
+ Per-solve raw variables are emitted as ``<name>__<solve>.parquet``
28
+ (one shard per cascade sub-solve). The manifest enumerates each shard
29
+ as a separate file entry under the same logical key — discovery is by
30
+ filename-glob ``<filename>`` interpreted as a glob pattern when it
31
+ contains ``*``; otherwise as an exact basename.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import os
38
+ from dataclasses import dataclass
39
+ from datetime import datetime, timezone
40
+ from pathlib import Path
41
+
42
+ _logger = logging.getLogger(__name__)
43
+
44
+
45
+ # Logical category → on-disk subdirectory (relative to ``work_folder``).
46
+ # ``output_raw`` is the canonical home of the per-solve LP variable parquets +
47
+ # the 4 handoff-capacity CSVs.
48
+ # ``output_processed`` is the LOGICAL home for the user-facing outputs;
49
+ # the actual files live under ``<output_location>/output_{parquet,csv,excel,plots}/``
50
+ # (which may sit outside ``work_folder``). When the processed bundle is
51
+ # co-located with ``work_folder`` (the default for tests / CLI without
52
+ # ``--output-location``), the manifest will find them via the symlink-free
53
+ # ``output_parquet`` lookup below.
54
+ CATEGORY_DIRS: dict[str, tuple[str, ...]] = {
55
+ "raw": ("output_raw",),
56
+ # Processed files appear under one of these subdirs depending on
57
+ # ``--write-methods``. The first match wins.
58
+ "processed": ("output_parquet", "output_csv", "output_excel"),
59
+ }
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ParquetSpec:
64
+ """Typed schema for one emitted output file (or one glob of shards).
65
+
66
+ Attributes
67
+ ----------
68
+ key
69
+ Registry key — short, stable name for this output (e.g.
70
+ ``"v_flow"``, ``"costs_dt_p"``). Matches the file basename
71
+ for processed outputs; matches the per-solve shard prefix for
72
+ raw variables.
73
+ category
74
+ ``"raw"`` (mapped to ``output_raw/`` under the bundle root)
75
+ or ``"processed"`` (mapped to ``output_processed/`` — the
76
+ logical category covering the user-facing output formats).
77
+ See :data:`CATEGORY_DIRS` for the on-disk directory mapping.
78
+ filename
79
+ Basename or glob pattern, relative to ``category`` directory.
80
+ Glob patterns (containing ``*``) are matched against the
81
+ directory listing at manifest-write time so per-solve shards
82
+ like ``v_flow__<solve>.parquet`` enumerate cleanly.
83
+ columns
84
+ Canonical column list. Validated by :func:`write_parquet`
85
+ against the frame. Empty tuple = "any columns" (skip
86
+ validation; appropriate for wide formats whose column set is
87
+ scenario-dependent).
88
+ indices
89
+ Subset of ``columns`` that form the row index in the canonical
90
+ wide-format DataFrame. Empty tuple if rows are positional /
91
+ the file has no logical index columns.
92
+ note
93
+ Human-readable description. When the file is conditional
94
+ (depends on a feature being active — e.g. CO2 cap, DC PF, …),
95
+ document the condition here so manifest readers can interpret a
96
+ missing file.
97
+ producer
98
+ ``module.function`` path of the writer that emits this file.
99
+ """
100
+
101
+ key: str
102
+ category: str
103
+ filename: str
104
+ columns: tuple[str, ...]
105
+ indices: tuple[str, ...]
106
+ note: str
107
+ producer: str
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # REGISTRY — populated by hand from a walk of the writers.
112
+ #
113
+ # Coverage status (Phase F first cut):
114
+ # * raw / v_*.parquet — COMPLETE (all 30 emitted variables documented)
115
+ # * raw / *.csv handoff capacity tables — COMPLETE (4 files)
116
+ # * processed — REPRESENTATIVE. ~95 unique table names exist; the
117
+ # registry below covers the high-traffic ones. Missing entries are
118
+ # surfaced (as warnings, not errors) by ``write_manifest`` so the
119
+ # coverage gap is visible without blocking the bundle write.
120
+ #
121
+ # Adding a new entry: pick a stable ``key``, set ``category``, set
122
+ # ``filename`` (glob if per-solve shards), document ``columns`` /
123
+ # ``indices`` / ``note`` / ``producer``. Order doesn't matter; the
124
+ # registry is a dict.
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _v(name: str, col_names: tuple[str, ...], *, has_period: bool = True,
129
+ has_time: bool = True, note: str = "") -> ParquetSpec:
130
+ """Helper for the per-solve raw LP variable specs."""
131
+ idx: list[str] = ["solve"]
132
+ if has_period:
133
+ idx.append("period")
134
+ if has_time:
135
+ idx.append("time")
136
+ return ParquetSpec(
137
+ key=name,
138
+ category="raw",
139
+ filename=f"{name}__*.parquet",
140
+ columns=tuple(col_names),
141
+ indices=tuple(idx),
142
+ note=note or f"per-solve LP variable {name}; one parquet per cascade sub-solve.",
143
+ producer="flextool.process_outputs.read_highs_solution.write_all_variables",
144
+ )
145
+
146
+
147
+ REGISTRY: dict[str, ParquetSpec] = {}
148
+
149
+
150
+ # -- raw / v_*.parquet (per-solve LP decision/slack/dual variables) ----------
151
+ for _spec in (
152
+ _v("v_flow", ("process", "source", "sink")),
153
+ _v("v_ramp", ("process", "source", "sink")),
154
+ _v("v_reserve", ("process", "reserve", "updown", "node")),
155
+ _v("v_state", ("node",)),
156
+ _v("v_online_linear", ("process",)),
157
+ _v("v_startup_linear", ("process",)),
158
+ _v("v_shutdown_linear", ("process",)),
159
+ _v("v_online_integer", ("process",)),
160
+ _v("v_startup_integer", ("process",)),
161
+ _v("v_shutdown_integer", ("process",)),
162
+ _v("v_angle", ("node",), note="DC power flow voltage angle; only emitted when DC PF active."),
163
+ _v("vq_state_up", ("node",)),
164
+ _v("vq_state_down", ("node",)),
165
+ _v("vq_reserve", ("reserve", "updown", "node_group")),
166
+ _v("vq_inertia", ("group",)),
167
+ _v("vq_non_synchronous", ("group",)),
168
+ _v("vq_state_up_group", ("group",)),
169
+ _v("vq_capacity_margin", ("group",), has_time=False),
170
+ _v("v_invest", ("entity",), has_time=False),
171
+ _v("v_divest", ("entity",), has_time=False),
172
+ _v("v_trade", ("commodity", "node", "tier"), has_time=False,
173
+ note="commodity-ladder trade; only emitted when commodity ladder active."),
174
+ _v("v_dual_node_balance", ("node",)),
175
+ _v("v_dual_reserve__upDown__group__period__t",
176
+ ("reserve", "updown", "node_group")),
177
+ _v("v_dual_invest_unit", ("unit",), has_time=False),
178
+ _v("v_dual_invest_connection", ("connection",), has_time=False),
179
+ _v("v_dual_invest_node", ("node",), has_time=False),
180
+ _v("v_dual_maxInvest_period", ("entity",), has_time=False,
181
+ note="MaxInvest-per-period dual; only emitted when maxInvest_period set."),
182
+ _v("v_dual_maxInvest_total", ("entity",), has_time=False,
183
+ note="MaxInvest-total dual; only emitted when maxInvest_total set."),
184
+ _v("v_dual_maxCumulative", ("entity",), has_time=False,
185
+ note="Cumulative-cap dual; only emitted when maxCumulative set."),
186
+ _v("v_dual_maxInvestGroup_period", ("group",), has_time=False,
187
+ note="MaxInvestGroup-period dual; only emitted when group invest cap set."),
188
+ _v("v_dual_maxInvestGroup_total", ("group",), has_time=False,
189
+ note="MaxInvestGroup-total dual; only emitted when group invest cap set."),
190
+ _v("v_dual_maxInvestGroup_cumulative", ("group",), has_time=False,
191
+ note="MaxInvestGroup-cumulative dual; only emitted when group invest cap set."),
192
+ _v("v_dual_minInvest_period", ("entity",), has_time=False,
193
+ note="MinInvest-per-period dual; only emitted when minInvest_period set."),
194
+ _v("v_dual_minInvest_total", ("entity",), has_time=False,
195
+ note="MinInvest-total dual; only emitted when minInvest_total set."),
196
+ _v("v_dual_minCumulative", ("entity",), has_time=False,
197
+ note="Cumulative-floor dual; only emitted when minCumulative set."),
198
+ _v("v_dual_minInvestGroup_period", ("group",), has_time=False,
199
+ note="MinInvestGroup-period dual; only emitted when group invest floor set."),
200
+ _v("v_dual_minInvestGroup_total", ("group",), has_time=False,
201
+ note="MinInvestGroup-total dual; only emitted when group invest floor set."),
202
+ _v("v_dual_minInvestGroup_cumulative", ("group",), has_time=False,
203
+ note="MinInvestGroup-cumulative dual; only emitted when group invest floor set."),
204
+ _v("v_dual_co2_max_period", ("group",), has_time=False,
205
+ note="CO2 cap dual (per-period); only emitted when CO2 cap set."),
206
+ _v("v_dual_co2_max_total", ("group",), has_period=False, has_time=False,
207
+ note="CO2 cap dual (total horizon); only emitted when total CO2 cap set."),
208
+ _v("v_obj", ("objective",), has_period=False, has_time=False,
209
+ note="Objective value (un-scaled); one row per solve."),
210
+ ):
211
+ REGISTRY[_spec.key] = _spec
212
+ del _spec
213
+
214
+
215
+ # NOTE: the legacy raw/handoff capacity CSVs (``entity_all_capacity.csv``,
216
+ # ``{unit,connection,node}_capacity__period.csv``) were removed 2026-08-07.
217
+ # They were a phase-3 byte-parity artifact with no live consumer; the
218
+ # user-facing capacity tables are the processed
219
+ # ``output_parquet/<scenario>/{unit,connection,node}_capacity_ed_p.parquet``
220
+ # below (produced by ``out_capacity``).
221
+
222
+
223
+ # -- processed / output_parquet/<scenario>/*.parquet -------------------------
224
+ # These are emitted by ``write_outputs`` when ``parquet`` ∈ ``write_methods``.
225
+ # Per-scenario subdir; see ``write_outputs.py:670-678`` for path construction.
226
+ # Wide / long shapes carry MultiIndex column / row layouts that we don't
227
+ # enumerate in detail (``columns`` left as ``()`` — "any columns").
228
+
229
+ def _proc(key: str, *, note: str, indices: tuple[str, ...] = (),
230
+ columns: tuple[str, ...] = (),
231
+ producer: str) -> ParquetSpec:
232
+ return ParquetSpec(
233
+ key=key, category="processed",
234
+ filename=f"{key}.parquet",
235
+ columns=columns, indices=indices,
236
+ note=note, producer=producer,
237
+ )
238
+
239
+
240
+ # Cost-related outputs
241
+ for _key, _producer, _note in (
242
+ ("discountFactors_d_p", "out_costs.generic", "Per-period discount factors (operations / investment)."),
243
+ ("entity_annuity_d_p", "out_costs.generic", "Per-entity annuity rows (when invest entities present)."),
244
+ ("costs_dt_p", "out_costs.cost_summaries", "Per-(period,time) cost decomposition."),
245
+ ("annualized_costs_d_p", "out_costs.cost_summaries", "Annualized cost summary by period."),
246
+ ("costs_discounted_d_p", "out_costs.cost_summaries", "Discounted cost summary by period."),
247
+ ("costs_discounted_p_", "out_costs.cost_summaries", "Discounted cost total over horizon."),
248
+ ("CO2__", "out_costs.CO2", "System-wide CO2 totals."),
249
+ ("CO2_d_g", "out_costs.CO2", "Per-(period,group) CO2 emissions."),
250
+ ("process_co2_d_eee", "out_costs.CO2", "Per-process CO2 contribution; only when CO2-emitting commodities present."),
251
+ ("co2_price_period_d_g", "out_ancillary.co2_duals", "CO2 price per period; only when CO2 cap active."),
252
+ ("co2_price_total_d_g", "out_ancillary.co2_duals", "CO2 price (total horizon); only when total CO2 cap active."),
253
+ ):
254
+ REGISTRY[_key] = _proc(
255
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
256
+ )
257
+ del _key, _producer, _note
258
+
259
+ # Capacity outputs
260
+ for _key, _producer, _note in (
261
+ ("unit_capacity_ed_p", "out_capacity.unit_capacity", "Per-(unit, period) capacity decomposition."),
262
+ ("connection_capacity_ed_p", "out_capacity.connection_capacity", "Per-(connection, period) capacity decomposition."),
263
+ ("node_capacity_ed_p", "out_capacity.node_capacity", "Per-(node, period) capacity decomposition; only for nodeState nodes."),
264
+ ):
265
+ REGISTRY[_key] = _proc(
266
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
267
+ )
268
+ del _key, _producer, _note
269
+
270
+ # Node / dispatch outputs
271
+ for _key, _producer, _note in (
272
+ ("node_dt_ep", "out_node.node_summary", "Per-(period,time) node balance."),
273
+ ("node_d_ep", "out_node.node_summary", "Per-period aggregated node balance."),
274
+ ("node_state_dt_e", "out_node.node_additional_results", "Per-(period,time) node state."),
275
+ ("node_inflow__dt", "out_node.node_additional_results", "Per-(period,time) node inflow."),
276
+ ("node_prices_dt_e", "out_node.node_additional_results", "Per-(period,time) node price (dual_node_balance)."),
277
+ ("node_slack_up_dt_e", "out_ancillary.slack_variables", "Per-(period,time) node up-slack."),
278
+ ("node_slack_down_dt_e", "out_ancillary.slack_variables", "Per-(period,time) node down-slack."),
279
+ ("node_slack_up_d_e", "out_ancillary.slack_variables", "Per-period node up-slack."),
280
+ ("node_slack_down_d_e", "out_ancillary.slack_variables", "Per-period node down-slack."),
281
+ ):
282
+ REGISTRY[_key] = _proc(
283
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
284
+ )
285
+ del _key, _producer, _note
286
+
287
+ # Unit-flow outputs
288
+ for _key, _producer, _note in (
289
+ ("unit_outputNode_dt_ee", "out_flows.unit_outputNode", "Per-(period,time) unit->node flow."),
290
+ ("unit_outputNode_d_ee", "out_flows.unit_outputNode", "Per-period unit->node flow."),
291
+ ("unit_inputNode_dt_ee", "out_flows.unit_inputNode", "Per-(period,time) node->unit flow."),
292
+ ("unit_inputNode_d_ee", "out_flows.unit_inputNode", "Per-period node->unit flow."),
293
+ ("unit_VRE_potential_outputNode_dt_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE potential; only with VRE units."),
294
+ ("unit_VRE_potential_outputNode_d_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE potential per period; only with VRE units."),
295
+ ("unit_curtailment_outputNode_dt_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE curtailment; only with VRE units."),
296
+ ("unit_curtailment_outputNode_d_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE curtailment per period; only with VRE units."),
297
+ ("unit_curtailment_share_outputNode_dt_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE curtailment share; only with VRE units."),
298
+ ("unit_curtailment_share_outputNode_d_ee", "out_flows.unit_VRE_curtailment_and_potential", "VRE curtailment share per period; only with VRE units."),
299
+ ("unit_ramp_inputs_dt_ee", "out_flows.unit_ramps", "Per-(period,time) input-side unit ramp; only with ramp limits set."),
300
+ ("unit_ramp_outputs_dt_ee", "out_flows.unit_ramps", "Per-(period,time) output-side unit ramp; only with ramp limits set."),
301
+ ("unit_online_dt_e", "out_flows.unit_online_and_startup", "Per-(period,time) unit online state; only with online tracking."),
302
+ ("unit_online_average_d_e", "out_flows.unit_online_and_startup", "Per-period unit online average; only with online tracking."),
303
+ ("unit_startup_d_e", "out_flows.unit_online_and_startup", "Per-period unit startup count; only with online tracking."),
304
+ ):
305
+ REGISTRY[_key] = _proc(
306
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
307
+ )
308
+ del _key, _producer, _note
309
+
310
+ # Connection-flow + DC PF outputs
311
+ for _key, _producer, _note in (
312
+ ("connection_dt_eee", "out_ancillary.connection", "Per-(period,time) net connection flow."),
313
+ ("connection_d_eee", "out_ancillary.connection", "Per-period net connection flow."),
314
+ ("connection_leftward_dt_eee", "out_ancillary.connection", "Per-(period,time) leftward connection flow."),
315
+ ("connection_leftward_d_eee", "out_ancillary.connection", "Per-period leftward connection flow."),
316
+ ("connection_rightward_dt_eee", "out_ancillary.connection", "Per-(period,time) rightward connection flow."),
317
+ ("connection_rightward_d_eee", "out_ancillary.connection", "Per-period rightward connection flow."),
318
+ ("connection_losses_dt_eee", "out_ancillary.connection", "Per-(period,time) connection losses."),
319
+ ("connection_losses_d_eee", "out_ancillary.connection", "Per-period connection losses."),
320
+ ("connection_dc_power_flow", "out_ancillary.dc_power_flow", "DC power flow per connection; only with DC PF active."),
321
+ ("node_dc_power_flow", "out_ancillary.dc_power_flow", "DC power flow per node; only with DC PF active."),
322
+ ("dc_angle_dt_e", "out_ancillary.dc_power_flow", "DC voltage angle per (period,time); only with DC PF active."),
323
+ ("dc_angle_diff_dt_e", "out_ancillary.dc_power_flow", "DC angle difference per branch; only with DC PF active."),
324
+ ):
325
+ REGISTRY[_key] = _proc(
326
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
327
+ )
328
+ del _key, _producer, _note
329
+
330
+ # Reserve / inertia / capacity-margin outputs
331
+ for _key, _producer, _note in (
332
+ ("process_reserve_upDown_node_dt_eppe", "out_ancillary.reserves", "Per-(period,time) reserve provision; only with reserves active."),
333
+ ("process_reserve_average_d_eppe", "out_ancillary.reserves", "Per-period average reserve provision; only with reserves active."),
334
+ ("reserve_prices_dt_ppg", "out_ancillary.reserves", "Per-(period,time) reserve price (dual); only with reserves active."),
335
+ ):
336
+ REGISTRY[_key] = _proc(
337
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
338
+ )
339
+ del _key, _producer, _note
340
+
341
+ # Investment-dual outputs
342
+ for _key, _producer, _note in (
343
+ ("dual_invest_effective_unit_d_e", "out_ancillary.investment_duals", "Effective investment dual for units."),
344
+ ("dual_invest_effective_connection_d_e", "out_ancillary.investment_duals", "Effective investment dual for connections."),
345
+ ("dual_invest_effective_node_d_e", "out_ancillary.investment_duals", "Effective investment dual for nodes."),
346
+ ):
347
+ REGISTRY[_key] = _proc(
348
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
349
+ )
350
+ del _key, _producer, _note
351
+
352
+ # Group-level dispatch / VRE-share / inertia / slack outputs
353
+ for _key, _producer, _note in (
354
+ ("nodeGroup_gdt_p", "out_group.nodeGroup_indicators", "Per-(group, period, time) indicators."),
355
+ ("nodeGroup_gd_p", "out_group.nodeGroup_indicators", "Per-(group, period) indicators."),
356
+ ("nodeGroup_VRE_share_dt_g", "out_group.nodeGroup_VRE_share", "Per-(period,time) VRE share by group."),
357
+ ("nodeGroup_VRE_share_d_g", "out_group.nodeGroup_VRE_share", "Per-period VRE share by group."),
358
+ ("nodeGroup_flows_dt_g", "out_group.nodeGroup_flows", "Per-(period,time) group net flow."),
359
+ ("nodeGroup_flows_d_g", "out_group.nodeGroup_flows", "Per-period group net flow."),
360
+ ("nodeGroup_flows_dt_gpe", "out_group.nodeGroup_flows", "Per-(period,time) group flow by participant."),
361
+ ("nodeGroup_flows_d_gpe", "out_group.nodeGroup_flows", "Per-period group flow by participant."),
362
+ ("nodeGroup_inertia_dt_g", "out_ancillary.inertia_results", "Per-(period,time) group inertia; only with inertia constraint active."),
363
+ ("nodeGroup_inertia_largest_flow_dt_g", "out_ancillary.inertia_results", "Largest-flow inertia decomposition; only with N-1 / dynamic reserves."),
364
+ ("nodeGroup_unit_node_inertia_dt_gee", "out_ancillary.inertia_results", "Per-unit inertia contribution; only with inertia constraint."),
365
+ ("nodeGroup_slack_capacity_margin_d_g", "out_ancillary.slack_variables", "Per-period capacity-margin slack by group."),
366
+ ("nodeGroup_slack_inertia_d_g", "out_ancillary.slack_variables", "Per-period inertia slack by group."),
367
+ ("nodeGroup_slack_inertia_dt_g", "out_ancillary.slack_variables", "Per-(period,time) inertia slack."),
368
+ ("nodeGroup_slack_nonsync_d_g", "out_ancillary.slack_variables", "Per-period non-sync slack."),
369
+ ("nodeGroup_slack_nonsync_dt_g", "out_ancillary.slack_variables", "Per-(period,time) non-sync slack."),
370
+ ("nodeGroup_slack_reserve_d_eeg", "out_ancillary.slack_variables", "Per-period reserve slack."),
371
+ ("nodeGroup_slack_reserve_dt_eeg", "out_ancillary.slack_variables", "Per-(period,time) reserve slack."),
372
+ ("nodeGroup_total_inflow", "out_group.nodeGroup_total_inflow", "Total per-period node-group inflow."),
373
+ ("flowGroup_gd_p", "out_flowgroup.flowGroup_indicators", "Per-(period) flow-group indicators; only with flow groups configured."),
374
+ ("flowGroup_gd_t", "out_flowgroup.flowGroup_indicators", "Per-(period,time) SIGNED net flow per flow group (MW); only with flow groups configured."),
375
+ ("flowGroupIndicators", "out_ancillary.input_sets", "Set of flow groups configured for indicator output."),
376
+ ("nodeGroupIndicators", "out_ancillary.input_sets", "Set of node groups configured for indicator output."),
377
+ ("nodeGroupDispatch", "out_ancillary.input_sets", "Set of node groups configured for dispatch output."),
378
+ ):
379
+ REGISTRY[_key] = _proc(
380
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
381
+ )
382
+ del _key, _producer, _note
383
+
384
+ # Misc input-side sets propagated to processed outputs
385
+ for _key, _producer, _note in (
386
+ ("years_represented__d", "out_ancillary.input_sets", "Years represented per period (annualisation factor)."),
387
+ ("group_node", "out_ancillary.input_sets", "Group -> node membership set."),
388
+ ("group_process", "out_ancillary.input_sets", "Group -> process membership set."),
389
+ ("group_process_node", "out_ancillary.input_sets", "Group -> (process, node) membership set."),
390
+ ):
391
+ REGISTRY[_key] = _proc(
392
+ _key, note=_note, producer=f"flextool.process_outputs.{_producer}",
393
+ )
394
+ del _key, _producer, _note
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Public API
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ def write_parquet(spec_key: str, frame, work_folder: Path | str) -> Path:
403
+ """Validate ``frame`` against ``REGISTRY[spec_key]`` and write parquet.
404
+
405
+ Parameters
406
+ ----------
407
+ spec_key
408
+ Registry key. Must be present in :data:`REGISTRY`.
409
+ frame
410
+ ``polars.DataFrame``-like object exposing ``.columns`` and
411
+ ``.write_parquet(path)``. We accept duck-typed input so the
412
+ function works for both polars frames and any future writer
413
+ adapter.
414
+ work_folder
415
+ Bundle root. File lands at
416
+ ``work_folder / spec.category / spec.filename``. When the
417
+ spec's ``filename`` is a glob (contains ``*``), we error: the
418
+ typed write path is for single files only; per-solve shards are
419
+ managed by their existing writers.
420
+
421
+ Returns
422
+ -------
423
+ Path
424
+ Destination path written.
425
+
426
+ Raises
427
+ ------
428
+ KeyError
429
+ ``spec_key`` not in REGISTRY.
430
+ ValueError
431
+ ``frame``'s columns don't match ``spec.columns`` (when
432
+ ``spec.columns`` is non-empty). Also raised when the spec's
433
+ ``filename`` is a glob pattern.
434
+ """
435
+ if spec_key not in REGISTRY:
436
+ raise KeyError(
437
+ f"write_parquet: unknown spec key {spec_key!r}; add an entry "
438
+ f"to REGISTRY in flextool/engine_polars/_parquet_bundle.py.",
439
+ )
440
+ spec = REGISTRY[spec_key]
441
+ if "*" in spec.filename:
442
+ raise ValueError(
443
+ f"write_parquet: spec {spec_key!r} uses a glob filename "
444
+ f"({spec.filename!r}); per-shard writes belong to their existing "
445
+ f"writers (e.g. read_highs_solution.write_all_variables). "
446
+ f"Use this typed path only for single-file outputs.",
447
+ )
448
+
449
+ if spec.columns:
450
+ # Tolerate polars (``.columns`` is a list[str]) and pandas
451
+ # (``.columns`` is an Index). Normalise to a tuple of strings.
452
+ actual = tuple(str(c) for c in frame.columns)
453
+ if set(actual) != set(spec.columns):
454
+ raise ValueError(
455
+ f"write_parquet: frame columns {actual!r} do not match "
456
+ f"spec {spec_key!r} columns {spec.columns!r}.",
457
+ )
458
+
459
+ work_folder = Path(work_folder)
460
+ # Use the FIRST configured directory for that category (canonical
461
+ # write target). ``write_manifest`` searches all configured dirs.
462
+ subdirs = CATEGORY_DIRS.get(spec.category, (spec.category,))
463
+ out_dir = work_folder / subdirs[0]
464
+ out_dir.mkdir(parents=True, exist_ok=True)
465
+ out_path = out_dir / spec.filename
466
+
467
+ # Best-effort polars-style write; fall back to pandas-style.
468
+ if hasattr(frame, "write_parquet"):
469
+ frame.write_parquet(str(out_path))
470
+ elif hasattr(frame, "to_parquet"):
471
+ frame.to_parquet(str(out_path))
472
+ else:
473
+ raise TypeError(
474
+ f"write_parquet: frame of type {type(frame).__name__} exposes "
475
+ f"neither .write_parquet nor .to_parquet.",
476
+ )
477
+ return out_path
478
+
479
+
480
+ def _enumerate_files(work_folder: Path) -> list[dict]:
481
+ """Walk REGISTRY, resolve each spec to zero-or-more on-disk files.
482
+
483
+ Returns one entry per file present. Glob specs (``filename``
484
+ containing ``*``) expand to one entry per matching shard. Non-glob
485
+ specs always produce exactly one entry (``exists`` flags presence).
486
+ """
487
+ files: list[dict] = []
488
+ for spec in REGISTRY.values():
489
+ # Try every directory configured for this category. For "raw"
490
+ # there's only one (``output_raw``); for "processed" the file
491
+ # may be under any of ``output_parquet`` / ``output_csv`` /
492
+ # ``output_excel`` depending on ``--write-methods``. We collect
493
+ # all matches across configured dirs.
494
+ subdirs = CATEGORY_DIRS.get(spec.category, (spec.category,))
495
+ if "*" in spec.filename:
496
+ shards: list[Path] = []
497
+ for sub in subdirs:
498
+ d = work_folder / sub
499
+ if d.is_dir():
500
+ shards.extend(sorted(d.glob(spec.filename)))
501
+ if not shards:
502
+ # Document the absent variable so readers know to expect
503
+ # zero shards (as opposed to "this spec doesn't exist").
504
+ files.append(_file_entry(spec, subdirs[0], None, exists=False))
505
+ continue
506
+ for shard in shards:
507
+ # Path relative to work_folder gives the consumer enough
508
+ # context to find the file.
509
+ rel_dir = shard.parent.relative_to(work_folder).as_posix()
510
+ files.append(_file_entry(spec, rel_dir, shard, exists=True))
511
+ else:
512
+ found = None
513
+ for sub in subdirs:
514
+ candidate = work_folder / sub / spec.filename
515
+ if candidate.is_file():
516
+ found = (sub, candidate)
517
+ break
518
+ if found is not None:
519
+ files.append(_file_entry(spec, found[0], found[1], exists=True))
520
+ else:
521
+ files.append(_file_entry(spec, subdirs[0], None, exists=False))
522
+ return files
523
+
524
+
525
+ def _file_entry(spec: ParquetSpec, subdir: str,
526
+ resolved: Path | None, *, exists: bool) -> dict:
527
+ """Build one manifest ``files[]`` entry.
528
+
529
+ ``subdir`` is the on-disk subdirectory (relative to the bundle root)
530
+ where the file lives — e.g. ``"output_raw"`` or ``"output_parquet"``.
531
+ """
532
+ if resolved is None:
533
+ rel_path = f"{subdir}/{spec.filename}"
534
+ size = None
535
+ else:
536
+ rel_path = f"{subdir}/{resolved.name}"
537
+ try:
538
+ size = resolved.stat().st_size if exists else None
539
+ except OSError:
540
+ size = None
541
+ return {
542
+ "key": spec.key,
543
+ "category": spec.category,
544
+ "path": rel_path,
545
+ "exists": exists,
546
+ "size_bytes": size,
547
+ "columns": list(spec.columns),
548
+ "indices": list(spec.indices),
549
+ "note": spec.note,
550
+ "producer": spec.producer,
551
+ }
552
+
553
+
554
+ def _registry_filenames(category: str) -> set[str]:
555
+ """Set of REGISTRY ``filename`` basenames for a category (glob-aware)."""
556
+ out: set[str] = set()
557
+ for spec in REGISTRY.values():
558
+ if spec.category != category:
559
+ continue
560
+ out.add(spec.filename)
561
+ return out
562
+
563
+
564
+ def _is_known(name: str, registered: set[str]) -> bool:
565
+ """Match a basename against either an exact filename or a glob pattern."""
566
+ import fnmatch
567
+ for pattern in registered:
568
+ if "*" in pattern:
569
+ if fnmatch.fnmatch(name, pattern):
570
+ return True
571
+ elif name == pattern:
572
+ return True
573
+ return False
574
+
575
+
576
+ def _is_per_roll_slice_intermediate(name: str) -> bool:
577
+ """True for the multi-solve output-union per-roll scratch parquets.
578
+
579
+ The output-union fix (``persist_realized_slice``) writes one
580
+ ``<attr>__<solve>.parquet`` per cascade sub-solve for every per-roll-
581
+ VARYING parameter and set, then ``union_realized_slice`` concatenates
582
+ them at output time. ``output_raw/`` is the cascade's intermediate
583
+ stash: ``cmd_run_flextool`` ``shutil.rmtree``s it after consumption
584
+ (unless ``--csv-dump``), so these slices are never a persisted
585
+ artifact and there is nothing for a manifest reader to consume.
586
+
587
+ Unlike the LP variable shards (a fixed, enumerable set documented in
588
+ REGISTRY), the param/set attrs are schema-driven and dynamic — pinning
589
+ each as a ParquetSpec would be a registry-completeness maintenance trap
590
+ that drifts on every schema change. We therefore recognise them by
591
+ their ``<attr>__<solve>.parquet`` convention and suppress the advisory
592
+ "no REGISTRY entry" warning for them (it fired thousands of times per
593
+ rolling run). This deliberately does NOT register them: they carry no
594
+ downstream contract.
595
+ """
596
+ return name.endswith(".parquet") and "__" in name
597
+
598
+
599
+ def _warn_coverage_gaps(work_folder: Path, files: list[dict]) -> None:
600
+ """Log warnings for:
601
+ 1. files in ``output_raw/`` with no matching REGISTRY entry,
602
+ 2. unconditional REGISTRY entries whose file is missing.
603
+
604
+ Both are advisory — the manifest is still written. ``output_processed``
605
+ is intentionally NOT walked here: its layout depends on
606
+ ``output_location`` + ``--write-methods``, which the bundle module
607
+ doesn't see.
608
+ """
609
+ raw_dir = work_folder / "output_raw"
610
+ if raw_dir.is_dir():
611
+ registered = _registry_filenames("raw")
612
+ for entry in os.listdir(raw_dir):
613
+ if entry.startswith("."):
614
+ continue
615
+ full = raw_dir / entry
616
+ if not full.is_file():
617
+ continue
618
+ # The manifest is the writer's OWN output, not a registered
619
+ # bundle file — never warn about it.
620
+ if entry == "manifest.json":
621
+ continue
622
+ if _is_known(entry, registered):
623
+ continue
624
+ # Per-roll realized-slice scratch (params/sets persisted by the
625
+ # multi-solve output union) is intermediate-only and intentionally
626
+ # unregistered — don't warn. See ``_is_per_roll_slice_intermediate``.
627
+ if _is_per_roll_slice_intermediate(entry):
628
+ continue
629
+ _logger.warning(
630
+ "manifest: file in output_raw/ with no REGISTRY entry: %s",
631
+ entry,
632
+ )
633
+
634
+ note_conditional = ("only when", "only with", "only for", "only emitted",
635
+ "conditional")
636
+ for record in files:
637
+ if record["exists"]:
638
+ continue
639
+ note = record["note"].lower()
640
+ if any(tok in note for tok in note_conditional):
641
+ continue
642
+ # Skip warnings for processed/* — coverage there is incomplete by design.
643
+ if record["category"] == "processed":
644
+ continue
645
+ # Skip when the entire raw dir is missing (run aborted before
646
+ # any output was emitted) — every entry would warn redundantly.
647
+ subdirs = CATEGORY_DIRS.get(record["category"], (record["category"],))
648
+ if not any((work_folder / sub).is_dir() for sub in subdirs):
649
+ continue
650
+ _logger.warning(
651
+ "manifest: registered file missing on disk: %s "
652
+ "(REGISTRY key %r)", record["path"], record["key"],
653
+ )
654
+
655
+
656
+ def write_manifest(work_folder: Path | str) -> Path:
657
+ """Emit ``work_folder / output_raw / manifest.json`` describing the output bundle.
658
+
659
+ Idempotent — safe to call multiple times. Each call regenerates
660
+ the file from the current on-disk state.
661
+
662
+ The manifest schema is::
663
+
664
+ {
665
+ "version": "1",
666
+ "generated_at": "<iso8601 utc>",
667
+ "bundle_root": "<absolute work_folder path>",
668
+ "files": [
669
+ {
670
+ "key": "<registry key>",
671
+ "category": "raw" | "processed",
672
+ "path": "<category>/<basename>",
673
+ "exists": true | false,
674
+ "size_bytes": <int> | null,
675
+ "columns": [...],
676
+ "indices": [...],
677
+ "note": "<human description>",
678
+ "producer": "<module.function path>"
679
+ },
680
+ ...
681
+ ]
682
+ }
683
+
684
+ Per the spec, the manifest only documents files that ARE present
685
+ on disk (``exists: true``). Missing files are still listed (with
686
+ ``exists: false``) when their REGISTRY entry is unconditional, so
687
+ a reader can distinguish "we didn't run that variable" from "we
688
+ ran it and emitted a real file". Conditional entries (those with
689
+ ``"only when"`` / ``"only with"`` / ``"only emitted"`` /
690
+ ``"only for"`` in ``note``) silently skip when absent.
691
+ """
692
+ work_folder = Path(work_folder)
693
+ files = _enumerate_files(work_folder)
694
+ _warn_coverage_gaps(work_folder, files)
695
+
696
+ manifest = {
697
+ "version": "1",
698
+ "generated_at": datetime.now(timezone.utc).isoformat(),
699
+ "bundle_root": str(work_folder.resolve()),
700
+ "files": files,
701
+ }
702
+
703
+ # Land the manifest under ``output_raw/`` alongside the per-solve
704
+ # parquets it enumerates. Keeps ``work_folder`` root clean when
705
+ # Spine Toolbox (or any other driver) sets ``work_folder`` to a
706
+ # shared location such as the repo root. Paths inside the manifest
707
+ # remain relative to ``work_folder`` (resolved via ``bundle_root``),
708
+ # so readers can locate every artifact without parsing the manifest
709
+ # path itself.
710
+ manifest_dir = work_folder / "output_raw"
711
+ manifest_dir.mkdir(parents=True, exist_ok=True)
712
+ manifest_path = manifest_dir / "manifest.json"
713
+ with open(manifest_path, "w", encoding="utf-8") as fh:
714
+ json.dump(manifest, fh, indent=2)
715
+ fh.write("\n")
716
+ _logger.debug(
717
+ "wrote manifest %s (%d entries; %d present on disk)",
718
+ manifest_path, len(files), sum(1 for f in files if f["exists"]),
719
+ )
720
+ return manifest_path
721
+
722
+
723
+ __all__ = ["ParquetSpec", "REGISTRY", "write_parquet", "write_manifest"]