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,881 @@
1
+ """Cluster C — profile cascade (Δ.7).
2
+
3
+ Lazy-polars port of flextool's
4
+ ``preprocessing/entity_period_calc_params.py::write_pdtProfile``
5
+ algorithm — the 5-branch fallback that resolves
6
+ ``profile.profile`` parameter values to per-(profile, d, t) rows.
7
+
8
+ Cluster C field (per
9
+ ``audit/native_data_path_design_derived_clusters.md``):
10
+
11
+ * ``p_profile_value`` — per-(f, d, t) profile value, resolved across a
12
+ multi-tier cascade.
13
+
14
+ Algorithm (mirror of ``write_pdtProfile`` at
15
+ ``entity_period_calc_params.py:767-947``)
16
+ -----------------------------------------------------------------
17
+
18
+ For each (profile p, dispatch period d, timestep t) ∈ profile × dt the
19
+ output value is picked by the first matching branch:
20
+
21
+ 1. **Stochastic UNION fold** — when ``p`` is referenced by a
22
+ stochastic relationship class (``unit__node__profile``,
23
+ ``connection__profile``, ``node__profile`` whose entity is in a
24
+ ``include_stochastics`` group), sum
25
+ ``Σ_{tb,ts} pbt_profile[p, tb, ts, t]`` over
26
+ ``(tb, ts) ∈ tb_for_d[d] × ts_for_d[d]``. Skip if no hit.
27
+
28
+ 2. **Parent-period fold** — for each parent ``pe`` of ``d``
29
+ (``period__branch[d_anchor=pe, branch=d]``), sum
30
+ ``Σ_{tb,ts} pbt_profile[p, tb, ts, t]`` over
31
+ ``(tb, ts) ∈ tb_for_d[pe] × ts_for_d[d]``. Skip if no hit.
32
+
33
+ 3. **Time series** — ``pt_profile[(p, t)]`` (``profile.profile`` of
34
+ runtime type ``Map(time → float)`` or ``time_series``).
35
+
36
+ 4. **Scalar** — ``p_profile[p]`` (``profile.profile`` of runtime type
37
+ ``float`` / ``str`` / ``bool``).
38
+
39
+ 5. **Zero** — fallback.
40
+
41
+ Branches 1 and 2 require the per-solve scaffolding ``period__branch``,
42
+ ``solve_branch__time_branch``, ``first_timesteps``, and
43
+ ``groupIncludeStochastics`` / membership tables. Δ.7 keeps these in the
44
+ workdir-CSV layer for the stochastic path; the chain-runner inputs are
45
+ preserved. The deterministic path (branches 3-5) is fully native.
46
+
47
+ Architectural notes
48
+ -------------------
49
+
50
+ * **Drop the ``_check_canonical_keys`` predicate.** The Δ.6 close
51
+ stanza identified ``_derived_params.py:p_profile_value_from_source``
52
+ as guarding against non-canonical Map keys (``x``/``i`` from
53
+ zero-``index_name`` Spine values) by returning ``None`` and falling
54
+ back to the CSV path. Cluster C drops the predicate: the stochastic
55
+ 3d_map case is now handled explicitly via the per-solve CSV, the
56
+ deterministic cases are handled via per-row dispatch on the raw
57
+ parameter value's runtime type rather than column-name sniffing.
58
+ * **Lazy polars throughout.** Every helper returns a
59
+ :class:`pl.LazyFrame`; the public ``apply_profile_cascade`` boundary
60
+ collects once.
61
+ * **Per-solve workdir CSVs for tiers 1 and 2.** Mirroring Cluster B's
62
+ ``edd_history`` / ``ppec_handoff`` boundary (Δ.6), the stochastic
63
+ scaffolding lives in ``solve_data/*.csv`` until the chain runner
64
+ surfaces it in-memory (Δ.8+).
65
+ """
66
+ from __future__ import annotations
67
+
68
+ from pathlib import Path
69
+ from typing import TYPE_CHECKING
70
+
71
+ import polars as pl
72
+
73
+ from polar_high import Param
74
+
75
+ from ._emit_provider_io import _provider_key
76
+ from ._axis_enums import (
77
+ alias_to_axis,
78
+ schema_dtype,
79
+ )
80
+
81
+
82
+ # Substrate handle for the cascade-wide axis enum vocabulary.
83
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
84
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
85
+ # ContextVar) when this is ``None``, so substrate sites pick up
86
+ # activation set by ``load_flextool`` automatically.
87
+ _enums: "dict | None" = None
88
+
89
+
90
+ def _cell_str(value: "object | None") -> str:
91
+ """Reproduce a ``csv.reader`` cell string for a native frame value.
92
+
93
+ ``DataFrame.write_csv`` renders ``null`` as the empty string and every
94
+ other scalar as its textual form; ``csv.reader`` then reads those
95
+ strings back. Mirror that here so dict keys/values stay byte-identical
96
+ to the legacy CSV round-trip.
97
+ """
98
+ return "" if value is None else str(value)
99
+
100
+
101
+ def _provider_has_key(provider, path: "Path") -> bool:
102
+ """Provider-only existence check used by the profile cascade.
103
+ Returns ``False`` when *provider* is ``None`` or the key is
104
+ missing. The disk-fallback arm was removed in Step 2.5.
105
+ """
106
+ if provider is None:
107
+ return False
108
+ return provider.has(_provider_key(path))
109
+
110
+
111
+ def _provider_read(provider, path: "Path") -> "pl.DataFrame":
112
+ """Provider-only read used by the profile cascade. Guard with
113
+ :func:`_provider_has_key` first; calling without a provider
114
+ raises :class:`ValueError`.
115
+ """
116
+ if provider is None:
117
+ raise ValueError(
118
+ "_provider_read requires a FlexDataProvider; guard with "
119
+ "_provider_has_key first."
120
+ )
121
+ return provider.get(_provider_key(path))
122
+
123
+ if TYPE_CHECKING:
124
+ from flextool.engine_polars._input_source import InputSource
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Per-entity profile classification: scalar / period / time / branch+t
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _classify_profile_rows(source: "InputSource") -> dict[str, str]:
133
+ """Return ``{profile_name: tier}`` where tier ∈ {``scalar``,
134
+ ``period``, ``time``, ``stochastic``}.
135
+
136
+ Reads ``profile.profile`` raw values from the source and
137
+ dispatches on Python type:
138
+
139
+ * ``float`` / ``str`` / ``bool`` → tier ``scalar`` (``p_profile.csv``).
140
+ * Map with ``index_name=='time'`` → tier ``time`` (``pt_profile.csv``).
141
+ * Map with ``index_name=='period'`` → tier ``period``
142
+ (``pd_profile.csv`` — note: not consumed by ``write_pdtProfile``).
143
+ * 3-deep Map → tier ``stochastic`` (``pbt_profile.csv``).
144
+ * Other Map shapes (deterministic 1d_map, 2d_map(period, t)) →
145
+ tier ``period`` or ``time`` per outermost ``index_name``.
146
+
147
+ Profiles absent from the parameter table are not in the returned
148
+ dict. The caller treats them as tier 5 (zero fallback).
149
+
150
+ Implementation strategy: peek at the SpineDbReader's per-row raw
151
+ value via ``_param_rows`` when available; fall back to column-name
152
+ heuristics on the CSV-shaped frame for sources that don't expose
153
+ raw parameter values (``InMemoryReader``).
154
+ """
155
+ # Try to access the typed raw values via SpineDbReader's internals.
156
+ rows_by_name: dict[str, object] = {}
157
+ if hasattr(source, "_class_name_to_id") and hasattr(source, "_param_rows"):
158
+ cls_id = source._class_name_to_id.get("profile") # type: ignore[attr-defined]
159
+ if cls_id is not None:
160
+ pdef = source._pdef_by_class_name.get((cls_id, "profile")) # type: ignore[attr-defined]
161
+ if pdef is not None:
162
+ raw_rows = source._param_rows.get((cls_id, pdef["id"]), []) # type: ignore[attr-defined]
163
+ for eid, v in raw_rows:
164
+ name = source._entity_by_id[eid][1] # type: ignore[attr-defined]
165
+ rows_by_name[name] = v
166
+ if rows_by_name:
167
+ return {n: _tier_for_value(v) for n, v in rows_by_name.items()}
168
+ # Column-shape fallback for InMemoryReader (used in unit tests).
169
+ try:
170
+ df = source.parameter("profile", "profile")
171
+ except KeyError:
172
+ return {}
173
+ if df.height == 0:
174
+ return {}
175
+ cols = set(df.columns)
176
+ out: dict[str, str] = {}
177
+ for name in df["name"].unique().to_list():
178
+ if cols == {"name", "value"}:
179
+ out[name] = "scalar"
180
+ elif cols == {"name", "period", "value"}:
181
+ out[name] = "period"
182
+ elif cols == {"name", "t", "value"}:
183
+ out[name] = "time"
184
+ elif cols == {"name", "period", "t", "value"}:
185
+ # 2d_map(period, t) — fold as time-axis per period. Treat
186
+ # as a multi-row variant of "time" here; the resolver knows.
187
+ out[name] = "period_time"
188
+ else:
189
+ # Generic / stochastic 3d_map — defer to workdir-CSV path.
190
+ out[name] = "stochastic"
191
+ return out
192
+
193
+
194
+ def _tier_for_value(v: object) -> str:
195
+ """Classify a single raw Spine parameter value.
196
+
197
+ Mirror of the input-writer dispatch logic.
198
+ """
199
+ from spinedb_api.parameter_value import (
200
+ Map, TimeSeries, Array,
201
+ )
202
+ if not isinstance(v, (Map, TimeSeries, Array)):
203
+ return "scalar"
204
+ if isinstance(v, TimeSeries):
205
+ return "time"
206
+ if isinstance(v, Map):
207
+ # Walk to find depth + outer index name.
208
+ depth = 0
209
+ cur: object = v
210
+ while isinstance(cur, Map):
211
+ depth += 1
212
+ if not cur.values:
213
+ break
214
+ cur = cur.values[0]
215
+ outer_idx = (v.index_name or "").lower()
216
+ if depth == 1:
217
+ if outer_idx == "period":
218
+ return "period"
219
+ if outer_idx == "time":
220
+ return "time"
221
+ # Empty or generic index — assume time-axis (the typical
222
+ # flextool 1d_map default).
223
+ return "time"
224
+ if depth == 2:
225
+ if outer_idx == "period":
226
+ return "period_time"
227
+ # 2d_map without "period" as the outer key — treat as
228
+ # stochastic (defer to CSV) for safety.
229
+ return "stochastic"
230
+ # Deeper than 2 → 3d_map (branch × time_start × t).
231
+ return "stochastic"
232
+ # Array — uncommon for profiles; treat as time-axis.
233
+ return "time"
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Tier-3 / tier-4 / tier-5 builders (deterministic)
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def _profile_time_lf(source: "InputSource",
242
+ dt_lf: pl.LazyFrame,
243
+ time_profiles: list[str],
244
+ workdir: Path | None = None,
245
+ *,
246
+ provider: "object | None" = None,
247
+ ) -> pl.LazyFrame:
248
+ """Lazy ``[f, d, t, value]`` for time-axis profiles.
249
+
250
+ Tier 3 of the cascade: profile values keyed by ``t`` only,
251
+ broadcast over the dispatch periods of dt.
252
+
253
+ Prefers ``workdir/solve_data/pt_profile.csv`` (per-solve averaged
254
+ values written by ``TimelineConfig.create_averaged_timeseries``)
255
+ when present — those values are aggregated to the solve's
256
+ ``new_stepduration``, matching the dispatch timeline. Falls back
257
+ to the raw Spine ``profile.profile`` parameter when the CSV is
258
+ absent (e.g. tests that bypass the runner).
259
+
260
+ The Spine fallback path: the source column may be ``t`` (when
261
+ ``index_name='time'``) or ``i`` (default index name). Either way
262
+ the column maps 1:1 to t. Mirrors how ``pdtNodeInflow`` already
263
+ sources averaged values from ``solve_data/pt_node_inflow.csv``.
264
+ """
265
+ empty_schema = {
266
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
267
+ }
268
+ if not time_profiles:
269
+ return pl.LazyFrame(schema=empty_schema)
270
+
271
+ # Prefer per-solve averaged CSV when the runner has produced it.
272
+ if workdir is not None:
273
+ csv_path = Path(workdir) / "solve_data" / "pt_profile.csv"
274
+ if _provider_has_key(provider, csv_path):
275
+ try:
276
+ csv_df = _provider_read(provider, csv_path)
277
+ csv_lf = (csv_df.lazy()
278
+ .filter(pl.col("profile").is_in(time_profiles))
279
+ .select(
280
+ alias_to_axis(
281
+ pl.col("profile").cast(pl.Utf8), "f"),
282
+ alias_to_axis(pl.col("time").cast(pl.Utf8), "t"),
283
+ pl.col("pt_profile")
284
+ .cast(pl.Float64, strict=False)
285
+ .alias("value"),
286
+ ))
287
+ return (csv_lf
288
+ .join(dt_lf, on="t", how="inner")
289
+ .select("f", "d", "t", "value"))
290
+ except Exception:
291
+ # Malformed CSV — fall through to Spine source.
292
+ pass
293
+
294
+ try:
295
+ raw = source.parameter("profile", "profile")
296
+ except KeyError:
297
+ return pl.LazyFrame(schema=empty_schema)
298
+ if raw.height == 0:
299
+ return pl.LazyFrame(schema=empty_schema)
300
+ cols = raw.columns
301
+ # Find the time-axis column. Prefer 't' (canonical) over 'i'
302
+ # (generic Map fallback) when both are present.
303
+ if "t" in cols:
304
+ t_col = "t"
305
+ elif "i" in cols:
306
+ t_col = "i"
307
+ elif "time" in cols:
308
+ t_col = "time"
309
+ else:
310
+ return pl.LazyFrame(schema=empty_schema)
311
+ raw_lf = (raw.lazy()
312
+ .filter(pl.col("name").is_in(time_profiles))
313
+ .select(alias_to_axis("name", "f"),
314
+ alias_to_axis(pl.col(t_col).cast(pl.Utf8), "t"),
315
+ pl.col("value").cast(pl.Float64, strict=False)))
316
+ return (raw_lf
317
+ .join(dt_lf, on="t", how="inner")
318
+ .select("f", "d", "t", "value"))
319
+
320
+
321
+ def _profile_period_time_lf(source: "InputSource",
322
+ dt_lf: pl.LazyFrame,
323
+ pt_profiles: list[str],
324
+ ) -> pl.LazyFrame:
325
+ """Lazy ``[f, d, t, value]`` for 2d_map(period, t) profiles.
326
+
327
+ The (d, t) keys are direct; only inner-join with dt_lf for
328
+ membership.
329
+ """
330
+ if not pt_profiles:
331
+ return pl.LazyFrame(schema={
332
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
333
+ })
334
+ try:
335
+ raw = source.parameter("profile", "profile")
336
+ except KeyError:
337
+ return pl.LazyFrame(schema={
338
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
339
+ })
340
+ if raw.height == 0:
341
+ return pl.LazyFrame(schema={
342
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
343
+ })
344
+ cols = raw.columns
345
+ if "period" not in cols:
346
+ return pl.LazyFrame(schema={
347
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
348
+ })
349
+ if "t" in cols:
350
+ t_col = "t"
351
+ elif "i" in cols:
352
+ t_col = "i"
353
+ else:
354
+ return pl.LazyFrame(schema={
355
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
356
+ })
357
+ raw_lf = (raw.lazy()
358
+ .filter(pl.col("name").is_in(pt_profiles))
359
+ .select(alias_to_axis("name", "f"),
360
+ alias_to_axis(pl.col("period").cast(pl.Utf8), "d"),
361
+ alias_to_axis(pl.col(t_col).cast(pl.Utf8), "t"),
362
+ pl.col("value").cast(pl.Float64, strict=False)))
363
+ return raw_lf.join(dt_lf, on=["d", "t"], how="inner")
364
+
365
+
366
+ def _profile_period_only_lf(source: "InputSource",
367
+ dt_lf: pl.LazyFrame,
368
+ period_profiles: list[str],
369
+ ) -> pl.LazyFrame:
370
+ """Lazy ``[f, d, t, value]`` for 1d_map(period) profiles.
371
+
372
+ Note: flextool's ``write_pdtProfile`` does **not** consult
373
+ ``pd_profile.csv``; the only deterministic period-keyed branch is
374
+ via the parent-period fold of ``pbt_profile`` (tier 2). This
375
+ helper exists for completeness and is wired in only when the
376
+ fixture explicitly populates a 1d_map_period profile (rare /
377
+ historical). Mirror behaviour: broadcast across t of the matching
378
+ period.
379
+ """
380
+ if not period_profiles:
381
+ return pl.LazyFrame(schema={
382
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
383
+ })
384
+ try:
385
+ raw = source.parameter("profile", "profile")
386
+ except KeyError:
387
+ return pl.LazyFrame(schema={
388
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
389
+ })
390
+ if raw.height == 0:
391
+ return pl.LazyFrame(schema={
392
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
393
+ })
394
+ cols = raw.columns
395
+ if "period" not in cols:
396
+ return pl.LazyFrame(schema={
397
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
398
+ })
399
+ raw_lf = (raw.lazy()
400
+ .filter(pl.col("name").is_in(period_profiles))
401
+ .select(alias_to_axis("name", "f"),
402
+ alias_to_axis(pl.col("period").cast(pl.Utf8), "d"),
403
+ pl.col("value").cast(pl.Float64, strict=False)))
404
+ return (raw_lf
405
+ .join(dt_lf, on="d", how="inner")
406
+ .select("f", "d", "t", "value"))
407
+
408
+
409
+ def _profile_scalar_lf(source: "InputSource",
410
+ dt_lf: pl.LazyFrame,
411
+ scalar_profiles: list[str],
412
+ ) -> pl.LazyFrame:
413
+ """Lazy ``[f, d, t, value]`` for scalar profiles.
414
+
415
+ Tier 4 of the cascade: scalar value broadcast over the full dt grid.
416
+ """
417
+ if not scalar_profiles:
418
+ return pl.LazyFrame(schema={
419
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
420
+ })
421
+ try:
422
+ raw = source.parameter("profile", "profile")
423
+ except KeyError:
424
+ return pl.LazyFrame(schema={
425
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
426
+ })
427
+ if raw.height == 0:
428
+ return pl.LazyFrame(schema={
429
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
430
+ })
431
+ raw_lf = (raw.lazy()
432
+ .filter(pl.col("name").is_in(scalar_profiles))
433
+ .select(alias_to_axis("name", "f"),
434
+ pl.col("value").cast(pl.Float64, strict=False))
435
+ .unique(subset=["f"]))
436
+ return (raw_lf
437
+ .join(dt_lf, how="cross")
438
+ .select("f", "d", "t", "value"))
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Tier-1 / tier-2 builders (stochastic — workdir-CSV-fed)
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def _profile_stochastic_lf(workdir: Path | None,
447
+ stoch_profiles: list[str],
448
+ *,
449
+ provider: "object | None" = None,
450
+ ) -> pl.LazyFrame:
451
+ """Lazy ``[f, d, t, value]`` for stochastic 3d_map profiles.
452
+
453
+ Branches 1 and 2 of flextool's ``write_pdtProfile`` cascade.
454
+
455
+ Per-solve scaffolding consumed (workdir/solve_data/):
456
+
457
+ * ``period__branch.csv`` — (anchor, branch) tuples; defines parents.
458
+ * ``solve_branch__time_branch.csv`` — (anchor, time_branch); the
459
+ stochastic-fold's outer-time-branch axis.
460
+ * ``first_timesteps.csv`` — (anchor, time_start); the
461
+ stochastic-fold's outer-time-start axis.
462
+ * ``input/pbt_profile.csv`` — (profile, branch, time_start, time,
463
+ value) — the 3d_map values themselves.
464
+ * ``input/groupIncludeStochastics.csv`` + relationship CSVs — gate
465
+ Branch 1 (per-profile is_stoch flag).
466
+
467
+ Output: lazy frame ``[f, d, t, value]`` covering only the
468
+ (profile, d, t) tuples that actually hit Branch 1 or Branch 2.
469
+ The caller layers tiers 3-5 over the gaps.
470
+ """
471
+ if workdir is None or not stoch_profiles:
472
+ return pl.LazyFrame(schema={
473
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
474
+ })
475
+ workdir = Path(workdir)
476
+ inp = workdir / "input"
477
+ sd = workdir / "solve_data"
478
+
479
+ # Read pbt_profile.csv: (profile, branch, time_start, time, pbt_profile).
480
+ pbt_path = inp / "pbt_profile.csv"
481
+ if not _provider_has_key(provider, pbt_path):
482
+ return pl.LazyFrame(schema={
483
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
484
+ })
485
+ try:
486
+ pbt = _provider_read(provider, pbt_path)
487
+ except Exception:
488
+ return pl.LazyFrame(schema={
489
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
490
+ })
491
+ if pbt.height == 0:
492
+ return pl.LazyFrame(schema={
493
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
494
+ })
495
+ pbt = pbt.rename({"pbt_profile": "value"})
496
+ pbt = pbt.filter(pl.col("profile").is_in(stoch_profiles))
497
+ pbt_lf = pbt.lazy().select(
498
+ alias_to_axis(pl.col("profile").cast(pl.Utf8), "f"),
499
+ pl.col("branch").cast(pl.Utf8).alias("tb"),
500
+ pl.col("time_start").cast(pl.Utf8).alias("ts"),
501
+ alias_to_axis(pl.col("time").cast(pl.Utf8), "t"),
502
+ pl.col("value").cast(pl.Float64, strict=False),
503
+ )
504
+
505
+ # Read per-solve scaffolding.
506
+ pe_for_d = _read_pairs(sd / "period__branch.csv", "branch", "period",
507
+ provider=provider)
508
+ tb_for_d = _read_pairs(sd / "solve_branch__time_branch.csv",
509
+ "period", "branch", provider=provider)
510
+ ts_for_d = _read_pairs(sd / "first_timesteps.csv", "period", "step",
511
+ provider=provider)
512
+
513
+ # Stochastic UNION gate per profile.
514
+ stoch_set = _read_stoch_profiles(inp, provider=provider)
515
+ stoch_active = [p for p in stoch_profiles if p in stoch_set]
516
+
517
+ # Branch 1: stochastic UNION fold.
518
+ # For each (p, d, t): sum pbt[p, tb, ts, t] over (tb, ts) ∈
519
+ # tb_for_d[d] × ts_for_d[d], iff p ∈ stoch_active.
520
+ parts: list[pl.LazyFrame] = []
521
+ if stoch_active and tb_for_d and ts_for_d:
522
+ # Build a join frame: (d, tb, ts) tuples.
523
+ d_tb_ts = []
524
+ for d, tbs in tb_for_d.items():
525
+ tss = ts_for_d.get(d, [])
526
+ for tb in tbs:
527
+ for ts in tss:
528
+ d_tb_ts.append((d, tb, ts))
529
+ if d_tb_ts:
530
+ d_tb_ts_lf = (pl.LazyFrame(
531
+ {"d": [r[0] for r in d_tb_ts],
532
+ "tb": [r[1] for r in d_tb_ts],
533
+ "ts": [r[2] for r in d_tb_ts]})
534
+ .with_columns(alias_to_axis("d", "d")))
535
+ stoch_lf = (pbt_lf
536
+ .filter(pl.col("f").is_in(stoch_active))
537
+ .join(d_tb_ts_lf, on=["tb", "ts"], how="inner")
538
+ .group_by(["f", "d", "t"])
539
+ .agg(pl.col("value").sum())
540
+ .with_columns(branch_priority=pl.lit(1, dtype=pl.Int8))
541
+ )
542
+ parts.append(stoch_lf)
543
+
544
+ # Branch 2: parent-period fold.
545
+ # For each (p, d, t): sum pbt[p, tb, ts, t] over (tb, ts) ∈
546
+ # tb_for_d[pe] × ts_for_d[d] for each pe ∈ pe_for_d[d].
547
+ if pe_for_d and tb_for_d and ts_for_d:
548
+ # Build (d, tb, ts) where tb is from each parent's tb_for_d.
549
+ d_tb_ts_pp = []
550
+ for d, parents in pe_for_d.items():
551
+ tss = ts_for_d.get(d, [])
552
+ for pe in parents:
553
+ tbs = tb_for_d.get(pe, [])
554
+ for tb in tbs:
555
+ for ts in tss:
556
+ d_tb_ts_pp.append((d, tb, ts))
557
+ if d_tb_ts_pp:
558
+ d_tb_ts_pp_lf = (pl.LazyFrame(
559
+ {"d": [r[0] for r in d_tb_ts_pp],
560
+ "tb": [r[1] for r in d_tb_ts_pp],
561
+ "ts": [r[2] for r in d_tb_ts_pp]})
562
+ .with_columns(alias_to_axis("d", "d")))
563
+ pp_lf = (pbt_lf
564
+ .join(d_tb_ts_pp_lf, on=["tb", "ts"], how="inner")
565
+ .group_by(["f", "d", "t"])
566
+ .agg(pl.col("value").sum())
567
+ .with_columns(branch_priority=pl.lit(2, dtype=pl.Int8))
568
+ )
569
+ parts.append(pp_lf)
570
+
571
+ if not parts:
572
+ return pl.LazyFrame(schema={
573
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
574
+ })
575
+ # Combine and resolve priority: lower priority wins.
576
+ union = pl.concat(parts, how="vertical")
577
+ return (union
578
+ .sort(["f", "d", "t", "branch_priority"])
579
+ .group_by(["f", "d", "t"])
580
+ .agg(pl.col("value").first())
581
+ .select("f", "d", "t", "value"))
582
+
583
+
584
+ def _read_pairs(path: Path, key_col: str, val_col: str,
585
+ *, provider: "object | None" = None,
586
+ ) -> dict[str, list[str]]:
587
+ """Read a 2-col CSV into ``{key: [val, …]}``. Empty / missing →
588
+ ``{}``.
589
+ """
590
+ if not _provider_has_key(provider, path):
591
+ return {}
592
+ out: dict[str, list[str]] = {}
593
+ try:
594
+ df = _provider_read(provider, path)
595
+ # The frame carries the header names as ``df.columns``; map by
596
+ # name exactly as the legacy ``header.index(...)`` lookup did.
597
+ if key_col not in df.columns or val_col not in df.columns:
598
+ return out
599
+ for k_cell, v_cell in df.select(key_col, val_col).iter_rows():
600
+ k = _cell_str(k_cell)
601
+ v = _cell_str(v_cell)
602
+ if k and v:
603
+ out.setdefault(k, []).append(v)
604
+ except Exception:
605
+ return {}
606
+ return out
607
+
608
+
609
+ def _read_stoch_profiles(inp: Path,
610
+ *,
611
+ provider: "object | None" = None) -> set[str]:
612
+ """Return the set of profile names referenced by stochastic
613
+ relationships. Mirrors
614
+ ``entity_period_calc_params.py:852-899``.
615
+ """
616
+ groups_stoch: set[str] = set()
617
+ p = inp / "groupIncludeStochastics.csv"
618
+ if _provider_has_key(provider, p):
619
+ try:
620
+ df = _provider_read(provider, p)
621
+ col = df.columns[0] if df.columns else None
622
+ if col is not None and df.height > 0:
623
+ groups_stoch = set(df[col].cast(pl.Utf8).to_list())
624
+ except Exception:
625
+ pass
626
+ if not groups_stoch:
627
+ return set()
628
+
629
+ stoch_processes: set[str] = set()
630
+ p = inp / "group__process.csv"
631
+ if _provider_has_key(provider, p):
632
+ try:
633
+ df = _provider_read(provider, p)
634
+ for row in df.iter_rows(named=True):
635
+ if row.get(df.columns[0]) in groups_stoch and row.get(df.columns[1]):
636
+ stoch_processes.add(row[df.columns[1]])
637
+ except Exception:
638
+ pass
639
+ stoch_nodes: set[str] = set()
640
+ p = inp / "group__node.csv"
641
+ if _provider_has_key(provider, p):
642
+ try:
643
+ df = _provider_read(provider, p)
644
+ for row in df.iter_rows(named=True):
645
+ if row.get(df.columns[0]) in groups_stoch and row.get(df.columns[1]):
646
+ stoch_nodes.add(row[df.columns[1]])
647
+ except Exception:
648
+ pass
649
+
650
+ stoch_profile: set[str] = set()
651
+ p = inp / "process__profile__profile_method.csv"
652
+ if _provider_has_key(provider, p):
653
+ try:
654
+ df = _provider_read(provider, p)
655
+ for row in df.iter_rows(named=True):
656
+ process = row.get("process")
657
+ profile = row.get("profile")
658
+ if process in stoch_processes and profile:
659
+ stoch_profile.add(profile)
660
+ except Exception:
661
+ pass
662
+ p = inp / "node__profile__profile_method.csv"
663
+ if _provider_has_key(provider, p):
664
+ try:
665
+ df = _provider_read(provider, p)
666
+ for row in df.iter_rows(named=True):
667
+ node = row.get("node")
668
+ profile = row.get("profile")
669
+ if node in stoch_nodes and profile:
670
+ stoch_profile.add(profile)
671
+ except Exception:
672
+ pass
673
+ p = inp / "process__node__profile__profile_method.csv"
674
+ if _provider_has_key(provider, p):
675
+ try:
676
+ df = _provider_read(provider, p)
677
+ for row in df.iter_rows(named=True):
678
+ process = row.get("process")
679
+ profile = row.get("profile")
680
+ if process in stoch_processes and profile:
681
+ stoch_profile.add(profile)
682
+ except Exception:
683
+ pass
684
+ return stoch_profile
685
+
686
+
687
+ # ---------------------------------------------------------------------------
688
+ # Public entry: p_profile_value_lf + apply_profile_cascade
689
+ # ---------------------------------------------------------------------------
690
+
691
+
692
+ def p_profile_value_lf(source: "InputSource",
693
+ dt: pl.DataFrame,
694
+ workdir: Path | None = None,
695
+ *,
696
+ provider: "object | None" = None,
697
+ ) -> pl.LazyFrame:
698
+ """Lazy ``[f, d, t, value]`` for the canonical ``p_profile_value``.
699
+
700
+ Entry point for the cluster C cascade. Composes the 5-branch
701
+ fallback from per-tier lazy frames:
702
+
703
+ 1. Stochastic / parent-period folds (tiers 1, 2) — when ``workdir``
704
+ is provided and stochastic profiles are present.
705
+ 2. Time-axis (tier 3) — for profiles with ``index_name='time'`` /
706
+ generic 1d_map values.
707
+ 3. Period-time (2d_map) — for profiles with ``index_name='period'``
708
+ and a child Map / TimeSeries.
709
+ 4. Period-only (1d_map(period)) — for profiles with
710
+ ``index_name='period'`` and scalar children. Note: flextool's
711
+ ``write_pdtProfile`` doesn't actually consult this tier — kept
712
+ for completeness when fixtures populate it.
713
+ 5. Scalar (tier 4) — broadcast across (d, t).
714
+ 6. Tier 5 (zero) — implicit; the LP-build site treats absence as 0.
715
+
716
+ The output schema is ``[f, d, t, value]``. Profiles absent from the
717
+ parameter table contribute no rows — the LP-build site's own gating
718
+ suppresses them where the schema requires.
719
+
720
+ Parameters
721
+ ----------
722
+ source
723
+ :class:`InputSource` for the spine data.
724
+ dt
725
+ Eager ``[d, t, ...]`` dispatch frame from
726
+ ``flex_data.dt`` / ``dt_and_step_duration_from_source``.
727
+ workdir
728
+ Optional ``Path`` for the per-solve workdir. Required for
729
+ stochastic profile resolution (tiers 1-2) which read
730
+ per-solve scaffolding from ``solve_data/``. When omitted, the
731
+ stochastic tiers are skipped (tiers 3-5 still emit).
732
+ """
733
+ if dt is None:
734
+ return pl.LazyFrame(schema={
735
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
736
+ })
737
+ # Defensive re-cast: callers occasionally pass dt with Utf8 d/t (e.g. stochastic
738
+ # fixtures where a CSV path bypasses upstream casting). Re-cast here so every
739
+ # tier helper composes on a consistent dtype.
740
+ dt_lf = (dt.lazy()
741
+ .select(alias_to_axis("d", "d"), alias_to_axis("t", "t"))
742
+ .unique())
743
+
744
+ # Provider-less workdir callers (e.g. cluster C parity tests) get an
745
+ # auto-built ephemeral Provider for the per-solve scaffolding the
746
+ # stochastic / averaged-time tiers consult. The cascade modules
747
+ # themselves remain disk-read-free (meta-test
748
+ # ``test_meta_provider_invariants.py``); the disk reads happen in
749
+ # ``_emit_provider_io.workdir_provider_for_paths`` which is on the
750
+ # PROVIDER_IMPL_ALLOWLIST.
751
+ if workdir is not None and provider is None:
752
+ from ._emit_provider_io import workdir_provider_for_paths
753
+ provider = workdir_provider_for_paths(workdir, [
754
+ "solve_data/period__branch.csv",
755
+ "solve_data/solve_branch__time_branch.csv",
756
+ "solve_data/first_timesteps.csv",
757
+ "solve_data/pt_profile.csv",
758
+ "input/pbt_profile.csv",
759
+ "input/groupIncludeStochastics.csv",
760
+ "input/group__process.csv",
761
+ "input/group__node.csv",
762
+ "input/process__profile__profile_method.csv",
763
+ "input/node__profile__profile_method.csv",
764
+ "input/process__node__profile__profile_method.csv",
765
+ ])
766
+
767
+ tiers = _classify_profile_rows(source)
768
+ scalar_profiles = sorted(p for p, t in tiers.items() if t == "scalar")
769
+ period_profiles = sorted(p for p, t in tiers.items() if t == "period")
770
+ time_profiles = sorted(p for p, t in tiers.items() if t == "time")
771
+ pt_profiles = sorted(p for p, t in tiers.items() if t == "period_time")
772
+ stoch_profiles = sorted(p for p, t in tiers.items() if t == "stochastic")
773
+
774
+ # Build per-tier frames with priority annotation; lower wins.
775
+ parts: list[pl.LazyFrame] = []
776
+
777
+ if stoch_profiles and workdir is not None:
778
+ st = _profile_stochastic_lf(workdir, stoch_profiles, provider=provider)
779
+ parts.append(st.with_columns(prio=pl.lit(1, dtype=pl.Int8)))
780
+
781
+ if pt_profiles:
782
+ pt = _profile_period_time_lf(source, dt_lf, pt_profiles)
783
+ parts.append(pt.with_columns(prio=pl.lit(2, dtype=pl.Int8)))
784
+
785
+ if time_profiles:
786
+ tt = _profile_time_lf(source, dt_lf, time_profiles, workdir=workdir,
787
+ provider=provider)
788
+ parts.append(tt.with_columns(prio=pl.lit(3, dtype=pl.Int8)))
789
+
790
+ if period_profiles:
791
+ pp = _profile_period_only_lf(source, dt_lf, period_profiles)
792
+ parts.append(pp.with_columns(prio=pl.lit(4, dtype=pl.Int8)))
793
+
794
+ if scalar_profiles:
795
+ sc = _profile_scalar_lf(source, dt_lf, scalar_profiles)
796
+ parts.append(sc.with_columns(prio=pl.lit(5, dtype=pl.Int8)))
797
+
798
+ # Tier 5 — zero fallback for every profile entity × dt cell.
799
+ # Mirrors flextool's ``write_pdtProfile`` final ``else: 0.0`` branch
800
+ # (entity_period_calc_params.py:946-947). Every profile listed in
801
+ # ``profile.csv`` (the entity table) gets a row for each (d, t) of
802
+ # the dispatch frame; tiers 1-4 then overlay the actual values.
803
+ try:
804
+ ents = source.entities("profile")
805
+ if ents.height > 0:
806
+ ent_lf = (ents.lazy()
807
+ .select(alias_to_axis("name", "f"))
808
+ .unique())
809
+ zero_lf = (ent_lf
810
+ .join(dt_lf, how="cross")
811
+ .with_columns(value=pl.lit(0.0, dtype=pl.Float64))
812
+ .with_columns(prio=pl.lit(99, dtype=pl.Int8)))
813
+ parts.append(zero_lf)
814
+ except KeyError:
815
+ pass
816
+
817
+ if not parts:
818
+ return pl.LazyFrame(schema={
819
+ "f": schema_dtype(_enums, "f"), "d": schema_dtype(_enums, "d"), "t": schema_dtype(_enums, "t"), "value": pl.Float64,
820
+ })
821
+
822
+ union = pl.concat(parts, how="vertical")
823
+ return (union
824
+ .sort(["f", "d", "t", "prio"])
825
+ .group_by(["f", "d", "t"])
826
+ .agg(pl.col("value").first())
827
+ .select("f", "d", "t", "value")
828
+ .sort(["f", "d", "t"]))
829
+
830
+
831
+ def p_profile_value_from_source_v2(source: "InputSource",
832
+ dt: pl.DataFrame,
833
+ workdir: Path | None = None,
834
+ *,
835
+ provider: "object | None" = None,
836
+ ) -> Param | None:
837
+ """Δ.7 lazy port replacement for
838
+ ``_derived_params.p_profile_value_from_source``.
839
+
840
+ Returns ``Param(("f", "d", "t"), frame)`` or ``None`` when the
841
+ cascade produces no rows (no profiles in the source, or all
842
+ profiles unresolved).
843
+ """
844
+ if dt is None:
845
+ return None
846
+ lf = p_profile_value_lf(source, dt, workdir=workdir, provider=provider)
847
+ out = lf.collect()
848
+ if out.height == 0:
849
+ return None
850
+ return Param(("f", "d", "t"), out)
851
+
852
+
853
+ def apply_profile_cascade(flex_data: object,
854
+ source: "InputSource",
855
+ workdir: Path | None,
856
+ *,
857
+ provider: "object | None" = None) -> None:
858
+ """Public entry: write ``flex_data.p_profile_value`` from cluster C.
859
+
860
+ Invoked after :func:`apply_derived_a` so ``flex_data.dt`` is
861
+ populated. Matches flextool's per-solve cascade: emits a Param
862
+ when at least one profile resolves; the helper returns ``None``
863
+ when no profile is declared (tier-5 / zero is implicit at the
864
+ LP-build site).
865
+
866
+ Δ.12b — assignment is unconditional; ``None`` is the explicit
867
+ "no profile data" signal (no silent fall-through to a CSV-loaded
868
+ seed value).
869
+ """
870
+ dt = getattr(flex_data, "dt", None)
871
+ if dt is None:
872
+ return
873
+ flex_data.p_profile_value = p_profile_value_from_source_v2(
874
+ source, dt, workdir=workdir, provider=provider)
875
+
876
+
877
+ __all__ = [
878
+ "p_profile_value_lf",
879
+ "p_profile_value_from_source_v2",
880
+ "apply_profile_cascade",
881
+ ]