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,438 @@
1
+ """Shared vectorize-per-roll helpers.
2
+
3
+ The heavy preprocessing families (``pdtProcess``, ``pdtNode``,
4
+ ``pdtProcess_{source,sink}``, ``pdtCommodity``, ``pdtGroup``, …) each
5
+ derive a dense ``(domain × dt)`` frame by looping over every
6
+ ``(entity, period, time)`` cell and calling a scalar cascade
7
+ (``PdtLookup.get`` and friends). That inner loop is recomputed on every
8
+ roll and is the per-roll preprocessing hotspot.
9
+
10
+ This module provides the reusable polars pieces that let a family
11
+ replace ONLY that inner compute with vectorized left-joins +
12
+ ``coalesce`` (cascade-priority order) + group-by-sum (the folds), still
13
+ **per roll, over the roll's own window** — no cache, no full-domain
14
+ frame, no slice. See ``specs/vectorize_per_roll.md`` for the full
15
+ design + the adversarial-critique corrections folded into the fold here.
16
+
17
+ Engine / rendering decoupling (design §3)
18
+ -----------------------------------------
19
+ The join/coalesce/group-by graph collects to a **Float64** value column
20
+ under either engine (eager ``df.collect()`` default, or
21
+ ``df.collect(engine="streaming")`` as a later per-family speed upgrade).
22
+ Rendering to the legacy ``repr(float)`` string form is ALWAYS the
23
+ post-collect Python loop :func:`_render_value_column` — engine
24
+ independent, so streaming is a one-line flag flip with no rendering
25
+ parity risk. **Never** render with ``.cast(Utf8)`` (it diverges from
26
+ ``repr`` on sci-notation exponent padding and ``NaN``/``nan``).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import polars as pl
31
+
32
+ __all__ = [
33
+ "build_entity_dt_grid",
34
+ "build_entity_period_grid",
35
+ "lift_dict_to_lookup",
36
+ "build_fold_frame",
37
+ "coalesce_value",
38
+ "collect_value_frame",
39
+ "_render_value_column",
40
+ ]
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Rendering — repr loop, engine-independent (NEVER .cast(Utf8)).
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _render_value_column(s: pl.Series) -> pl.Series:
48
+ """Render a Float64 value series to the legacy ``repr(v)`` strings.
49
+
50
+ Mirrors the legacy emitters' ``f",{repr(v)}\\n"`` exactly: a bare
51
+ ``repr(x)`` per cell (NOT ``repr(float(x))`` — Tier-B int-vs-float
52
+ cells must survive where a family emits them). This is the ONLY
53
+ supported render path; ``.cast(Utf8)`` diverges from ``repr`` on
54
+ sci-notation exponent padding and ``NaN``/``nan``.
55
+ """
56
+ return pl.Series("value", [repr(x) for x in s], dtype=pl.Utf8)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Base grid — entity-major ``domain × dt`` with integer order keys.
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def build_entity_dt_grid(
64
+ domain: list[tuple],
65
+ dt: list[tuple[str, str]],
66
+ *,
67
+ key_cols: list[str],
68
+ ) -> pl.DataFrame:
69
+ """Build the entity-major ``domain × dt`` grid.
70
+
71
+ *domain* is the legacy ``_read_pairs`` / ``_read_triples`` **list**
72
+ (order AND duplicates preserved — never ``.unique()`` it: S-M2). Its
73
+ tuples carry the entity-key columns named by *key_cols* (e.g.
74
+ ``["process", "param"]`` or ``["process", "side", "param"]``). *dt*
75
+ is the ``(period, time)`` list from ``steps_in_use``.
76
+
77
+ The result carries two integer order keys:
78
+
79
+ * ``__eo`` — the domain row position (entity-major ordering key),
80
+ * ``__to`` — the ``dt`` row position,
81
+
82
+ so the final ``.sort([__eo, __to])`` reproduces the legacy nested
83
+ ``for entity: for (d, t):`` emission order even under streaming
84
+ (which may reorder). An empty domain or dt yields a zero-row grid
85
+ with the correct schema (all key cols Utf8 + the two order keys).
86
+ """
87
+ n_keys = len(key_cols)
88
+ base_data: dict[str, list] = {
89
+ key_cols[i]: [row[i] for row in domain] for i in range(n_keys)
90
+ }
91
+ base_data["__eo"] = list(range(len(domain)))
92
+ base = pl.DataFrame(
93
+ base_data,
94
+ schema={
95
+ **{c: pl.Utf8 for c in key_cols},
96
+ "__eo": pl.Int64,
97
+ },
98
+ ).with_columns(pl.lit(1, dtype=pl.Int8).alias("__one"))
99
+
100
+ dt_df = pl.DataFrame(
101
+ {
102
+ "period": [d for (d, _t) in dt],
103
+ "time": [t for (_d, t) in dt],
104
+ "__to": list(range(len(dt))),
105
+ },
106
+ schema={"period": pl.Utf8, "time": pl.Utf8, "__to": pl.Int64},
107
+ ).with_columns(pl.lit(1, dtype=pl.Int8).alias("__one"))
108
+
109
+ return base.join(dt_df, on="__one", how="inner").drop("__one")
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Base grid — entity-major ``domain × period`` (period-only, no time).
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def build_entity_period_grid(
117
+ domain: list[tuple],
118
+ periods: list[str],
119
+ *,
120
+ key_cols: list[str],
121
+ ) -> pl.DataFrame:
122
+ """Build the entity-major ``domain × period`` grid (no time axis).
123
+
124
+ Mirror of :func:`build_entity_dt_grid` but with a single ``period``
125
+ axis instead of ``(period, time)`` — for the period-only families
126
+ (e.g. ``pdGroup``). *domain* is the legacy entity list (order AND
127
+ duplicates preserved — never ``.unique()`` it: S-M2), its tuples
128
+ carrying the entity-key columns named by *key_cols*. *periods* is the
129
+ ``period_in_use`` list (order AND duplicates preserved — never
130
+ ``.unique()`` it either).
131
+
132
+ The result carries two integer order keys:
133
+
134
+ * ``__eo`` — the domain row position (entity-major ordering key),
135
+ * ``__po`` — the ``periods`` row position,
136
+
137
+ so the final ``.sort([__eo, __po])`` reproduces the legacy nested
138
+ ``for entity: for d:`` emission order even under streaming (which may
139
+ reorder). An empty domain or periods list yields a zero-row grid with
140
+ the correct schema (all key cols Utf8 + period Utf8 + the two order
141
+ keys).
142
+ """
143
+ n_keys = len(key_cols)
144
+ base_data: dict[str, list] = {
145
+ key_cols[i]: [row[i] for row in domain] for i in range(n_keys)
146
+ }
147
+ base_data["__eo"] = list(range(len(domain)))
148
+ base = pl.DataFrame(
149
+ base_data,
150
+ schema={
151
+ **{c: pl.Utf8 for c in key_cols},
152
+ "__eo": pl.Int64,
153
+ },
154
+ ).with_columns(pl.lit(1, dtype=pl.Int8).alias("__one"))
155
+
156
+ period_df = pl.DataFrame(
157
+ {
158
+ "period": list(periods),
159
+ "__po": list(range(len(periods))),
160
+ },
161
+ schema={"period": pl.Utf8, "__po": pl.Int64},
162
+ ).with_columns(pl.lit(1, dtype=pl.Int8).alias("__one"))
163
+
164
+ return base.join(period_df, on="__one", how="inner").drop("__one")
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Lookup frames — lift a cascade dict to a Float64-valued lookup frame.
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def lift_dict_to_lookup(
172
+ d: dict,
173
+ key_cols: list[str],
174
+ value_col: str,
175
+ ) -> pl.DataFrame:
176
+ """Lift a cascade ``dict[tuple] -> float`` to a polars lookup frame.
177
+
178
+ Lift from the already-built (last-wins-deduped) dict, NOT the raw
179
+ CSV — lifting from CSV would re-introduce duplicate join keys and
180
+ explode the left-join (S-claim4). *key_cols* names the tuple
181
+ positions (all Utf8); *value_col* names the Float64 value column.
182
+
183
+ An empty dict yields an explicit empty-schema frame so the
184
+ downstream join-key dtypes still line up (Utf8 keys + Float64 value).
185
+ """
186
+ if not d:
187
+ return pl.DataFrame(
188
+ {c: [] for c in [*key_cols, value_col]},
189
+ schema={
190
+ **{c: pl.Utf8 for c in key_cols},
191
+ value_col: pl.Float64,
192
+ },
193
+ )
194
+ keys = list(d.keys())
195
+ n = len(key_cols)
196
+ if n == 1:
197
+ data = {key_cols[0]: [k if not isinstance(k, tuple) else k[0]
198
+ for k in keys]}
199
+ else:
200
+ data = {key_cols[i]: [k[i] for k in keys] for i in range(n)}
201
+ data[value_col] = list(d.values())
202
+ return pl.DataFrame(
203
+ data,
204
+ schema={
205
+ **{c: pl.Utf8 for c in key_cols},
206
+ value_col: pl.Float64,
207
+ },
208
+ )
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # The fold — stochastic (branch 1) + parent-period (branch 2).
213
+ # Critique-corrected: multi-parent multiplicity + stoch fall-through.
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def build_fold_frame(
217
+ *,
218
+ pbt: dict,
219
+ pbt_key_cols: list[str],
220
+ out_key_cols: list[str],
221
+ ts_for_d: dict[str, list[str]],
222
+ tb_for_d: dict[str, list[str]],
223
+ pe_for_d: dict[str, list[str]],
224
+ stoch_entities: set[str],
225
+ stoch_filter_cols: list[str],
226
+ periods: list[str],
227
+ ) -> pl.DataFrame | None:
228
+ """Vectorize the stochastic + parent-period fold (branches 1 & 2).
229
+
230
+ Reproduces ``PdtLookup.get`` branches 1-2 exactly (the scalar
231
+ ``_pdt_lookup.py:407-463`` cascade):
232
+
233
+ * **Branch 1 (stochastic):** for an entity whose ``stoch_filter_cols``
234
+ key is in *stoch_entities*, sum ``pbt[(e, …, tb, ts, t)]`` over
235
+ ``tb ∈ tb_for_d[d] × ts ∈ ts_for_d[d]``.
236
+ * **Branch 2 (parent-period):** sum ``pbt`` over
237
+ ``pe ∈ pe_for_d[d], tb ∈ tb_for_d[pe], ts ∈ ts_for_d[d]``.
238
+
239
+ Critique corrections vs the template (design §4):
240
+
241
+ * **Multi-parent multiplicity (S2):** the parent expansion KEEPS
242
+ ``pe`` in its key ``(period, pe, tb, ts)`` and is NOT
243
+ ``.unique()``-d, so a pbt value shared by two parents is added
244
+ once per parent — then the join output is group-by-summed on the
245
+ OUTPUT key.
246
+ * **Stoch fall-through (S2/§12.6):** the parent fold is NOT filtered
247
+ to non-stoch entities. ``v_stoch`` and ``v_parent`` are computed
248
+ as separate frames and coalesced ``coalesce(v_stoch, v_parent)``
249
+ (stoch-first preserves branch priority), so a stoch entity that
250
+ misses branch 1 can still hit branch 2.
251
+ * **Duplicate-row invariant (S3):** the expansion is NOT
252
+ ``.unique()``-d — production (``_read_pairs_to_dict``) does not
253
+ dedup, so it sums once per duplicate ``(tb)``/``(ts)``/``(pe)``
254
+ occurrence; matching that requires preserving duplicates here.
255
+
256
+ *pbt_key_cols* names the pbt entity-key columns (``["process",
257
+ "param"]`` for pdtProcess/pdtNode, ``["process", "side", "param"]``
258
+ for PerSide). *out_key_cols* names the OUTPUT group key (entity key
259
+ cols + ``["period", "time"]``). *stoch_filter_cols* names the
260
+ column(s) the stoch membership test keys on — note the PerSide M1
261
+ quirk: it filters on the ``process`` column ALONE, not the 3-col key.
262
+
263
+ Returns a ``(*out_key_cols, "v_fold")`` frame, or ``None`` when no
264
+ fold rows exist (no pbt / no expansion / empty join).
265
+ """
266
+ if not pbt:
267
+ return None
268
+
269
+ # --- pbt frame: entity-key cols + (tb, ts, time) + v_pbt ---------------
270
+ keys = list(pbt.keys())
271
+ n_ent = len(pbt_key_cols)
272
+ pbt_data: dict[str, list] = {
273
+ pbt_key_cols[i]: [k[i] for k in keys] for i in range(n_ent)
274
+ }
275
+ pbt_data["tb"] = [k[n_ent] for k in keys]
276
+ pbt_data["ts"] = [k[n_ent + 1] for k in keys]
277
+ pbt_data["time"] = [k[n_ent + 2] for k in keys]
278
+ pbt_data["v_pbt"] = list(pbt.values())
279
+ pbt_df = pl.DataFrame(
280
+ pbt_data,
281
+ schema={
282
+ **{c: pl.Utf8 for c in pbt_key_cols},
283
+ "tb": pl.Utf8,
284
+ "ts": pl.Utf8,
285
+ "time": pl.Utf8,
286
+ "v_pbt": pl.Float64,
287
+ },
288
+ )
289
+
290
+ # --- expansion rows (preserve duplicates — S3) -------------------------
291
+ # Stoch: (period, tb, ts) for ts in ts_for_d[d], tb in tb_for_d[d].
292
+ # Parent: (period, pe, tb, ts) for ts in ts_for_d[d], pe in pe_for_d[d],
293
+ # tb in tb_for_d[pe]. Keep pe (S2 multiplicity).
294
+ #
295
+ # The expansion is a function of the DISTINCT period set: the legacy
296
+ # scalar cascade recomputes the same fold value for every ``(d, t)``
297
+ # cell of a period, so the per-output-key group-by-sum below must see
298
+ # each ``(period, tb, ts)`` / ``(period, pe, tb, ts)`` term exactly
299
+ # once per period — NOT once per ``(d, t)`` cell. *periods* is the
300
+ # caller's ``[d for (d, _t) in dt]`` list, which repeats a period once
301
+ # per timestep; de-duplicate it order-preserving here so a multi-
302
+ # timestep period does not inflate the fold by its timestep count.
303
+ # (This is distinct from the S3 ``tb``/``ts``/``pe`` duplicate-row
304
+ # invariant, which is preserved verbatim within a single period.)
305
+ seen: set[str] = set()
306
+ uniq_periods: list[str] = []
307
+ for d in periods:
308
+ if d not in seen:
309
+ seen.add(d)
310
+ uniq_periods.append(d)
311
+ stoch_period: list[str] = []
312
+ stoch_tb: list[str] = []
313
+ stoch_ts: list[str] = []
314
+ par_period: list[str] = []
315
+ par_pe: list[str] = []
316
+ par_tb: list[str] = []
317
+ par_ts: list[str] = []
318
+ for d in uniq_periods:
319
+ ts_list = ts_for_d.get(d, ())
320
+ tb_list = tb_for_d.get(d, ())
321
+ pe_list = pe_for_d.get(d, ())
322
+ for ts in ts_list:
323
+ for tb in tb_list:
324
+ stoch_period.append(d)
325
+ stoch_tb.append(tb)
326
+ stoch_ts.append(ts)
327
+ for pe in pe_list:
328
+ for tb in tb_for_d.get(pe, ()):
329
+ par_period.append(d)
330
+ par_pe.append(pe)
331
+ par_tb.append(tb)
332
+ par_ts.append(ts)
333
+
334
+ stoch_list = list(stoch_entities)
335
+
336
+ # --- branch 1: stochastic fold ----------------------------------------
337
+ v_stoch = None
338
+ if stoch_period and stoch_list:
339
+ stoch_exp = pl.DataFrame(
340
+ {"period": stoch_period, "tb": stoch_tb, "ts": stoch_ts},
341
+ schema={"period": pl.Utf8, "tb": pl.Utf8, "ts": pl.Utf8},
342
+ )
343
+ j = pbt_df.join(stoch_exp, on=["tb", "ts"], how="inner")
344
+ # Stoch membership keys on stoch_filter_cols (process alone for
345
+ # PerSide; the full entity key for pdtProcess/pdtNode).
346
+ if len(stoch_filter_cols) == 1:
347
+ mask = pl.col(stoch_filter_cols[0]).is_in(stoch_list)
348
+ else:
349
+ # multi-col membership — stoch_entities holds tuples
350
+ stoch_struct = pl.struct(stoch_filter_cols)
351
+ mask = stoch_struct.is_in(stoch_list)
352
+ j = j.filter(mask)
353
+ if j.height > 0:
354
+ v_stoch = (
355
+ j.group_by(out_key_cols)
356
+ .agg(pl.col("v_pbt").sum().alias("v_stoch"))
357
+ )
358
+
359
+ # --- branch 2: parent-period fold (NO stoch filter — fall-through) -----
360
+ v_parent = None
361
+ if par_period:
362
+ par_exp = pl.DataFrame(
363
+ {
364
+ "period": par_period,
365
+ "pe": par_pe,
366
+ "tb": par_tb,
367
+ "ts": par_ts,
368
+ },
369
+ schema={
370
+ "period": pl.Utf8,
371
+ "pe": pl.Utf8,
372
+ "tb": pl.Utf8,
373
+ "ts": pl.Utf8,
374
+ },
375
+ )
376
+ j = pbt_df.join(par_exp, on=["tb", "ts"], how="inner")
377
+ if j.height > 0:
378
+ v_parent = (
379
+ j.group_by(out_key_cols)
380
+ .agg(pl.col("v_pbt").sum().alias("v_parent"))
381
+ )
382
+
383
+ # --- coalesce stoch-first ---------------------------------------------
384
+ if v_stoch is not None and v_parent is not None:
385
+ fold = (
386
+ v_stoch.join(
387
+ v_parent, on=out_key_cols, how="full", coalesce=True,
388
+ )
389
+ .with_columns(
390
+ pl.coalesce(pl.col("v_stoch"), pl.col("v_parent"))
391
+ .alias("v_fold")
392
+ )
393
+ .select([*out_key_cols, "v_fold"])
394
+ )
395
+ elif v_stoch is not None:
396
+ fold = v_stoch.rename({"v_stoch": "v_fold"})
397
+ elif v_parent is not None:
398
+ fold = v_parent.rename({"v_parent": "v_fold"})
399
+ else:
400
+ return None
401
+ return fold
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # Coalesce assembler + collect.
406
+ # ---------------------------------------------------------------------------
407
+
408
+ def coalesce_value(exprs: list[pl.Expr], alias: str = "value_f") -> pl.Expr:
409
+ """``pl.coalesce`` over *exprs* in cascade-priority order."""
410
+ return pl.coalesce(exprs).alias(alias)
411
+
412
+
413
+ def collect_value_frame(
414
+ lf: "pl.LazyFrame | pl.DataFrame",
415
+ *,
416
+ key_cols: list[str],
417
+ value_f_col: str = "value_f",
418
+ sort_cols: list[str] | None = None,
419
+ engine: str = "eager",
420
+ ) -> pl.DataFrame:
421
+ """Collect the join/coalesce graph and render the value column.
422
+
423
+ Collects under *engine* (``"eager"`` → ``lf.collect()``; anything
424
+ else → ``lf.collect(engine="streaming")``), sorts by *sort_cols*
425
+ (default ``["__eo", "__to"]`` for entity-major order), renders the
426
+ Float64 *value_f_col* via :func:`_render_value_column` AFTER collect
427
+ (engine-independent), and selects ``[*key_cols, "value"]``.
428
+ """
429
+ if sort_cols is None:
430
+ sort_cols = ["__eo", "__to"]
431
+ if isinstance(lf, pl.LazyFrame):
432
+ df = lf.collect() if engine == "eager" else lf.collect(
433
+ engine="streaming")
434
+ else:
435
+ df = lf
436
+ df = df.sort(sort_cols)
437
+ value = _render_value_column(df[value_f_col])
438
+ return df.select(key_cols).with_columns(value)