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,2186 @@
1
+ """First wave of Direct Param helpers (Γ.1).
2
+
3
+ Each function in this module takes an :class:`InputSource` and returns
4
+ a single :class:`polar_high.Param` (or, for entity-only sets, a
5
+ :class:`polars.DataFrame`) — the trivial Direct port of one Param's
6
+ loader logic. The body of each helper is at most:
7
+
8
+ * ``source.parameter(class, name)`` → polars frame,
9
+ * a column rename / dtype cast,
10
+ * return.
11
+
12
+ Lazy-evaluation pattern
13
+ -----------------------
14
+ ``InputSource.parameter()`` already collects internally (the source
15
+ plugin is the boundary). Helpers that compose multiple
16
+ ``source.parameter()`` calls use ``.lazy()`` to chain operations
17
+ before a single ``.collect()`` at the end. Single-call helpers stay
18
+ eager (no compositional benefit from going lazy).
19
+
20
+ The full sweep into ``input.py`` is Γ.2/Γ.3; Γ.1 wires only the chosen
21
+ representative subset. Each helper is here so a future helper-by-
22
+ helper migration replaces the corresponding CSV branch in ``input.py``
23
+ with a one-line call to the function below.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ import polars as pl
30
+
31
+ from polar_high import Param
32
+
33
+ from flextool.engine_polars._axis_enums import (cast_frame_axes,
34
+ get_global_axis_enums,
35
+ rename_to_axis)
36
+ from flextool.engine_polars._param_shapes import (
37
+ broadcast_to_period,
38
+ broadcast_to_period_time,
39
+ resolve_param_shape,
40
+ )
41
+ from flextool.engine_polars._solve_state import FlexToolConfigError
42
+
43
+ if TYPE_CHECKING:
44
+ from flextool.engine_polars._input_source import InputSource
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # §5.2.1 — object-class scalars (Direct, scalar default → broadcast)
49
+
50
+
51
+ def p_co2_content_from_source(source: "InputSource") -> Param | None:
52
+ """``commodity.co2_content`` → ``Param(("c",), [c, value])``.
53
+
54
+ Default ``None`` (§5.2.1) — None-skip per §4.5. Helpers that
55
+ depend on ``p_co2_content`` filter to entities with explicit values.
56
+ """
57
+ df = source.parameter("commodity", "co2_content")
58
+ if df.height == 0:
59
+ return None
60
+ return Param(("c",),
61
+ df.lazy().rename({"name": "c"}).select("c", "value"))
62
+
63
+
64
+ def p_constraint_constant_from_source(source: "InputSource") -> Param | None:
65
+ """``constraint.constant`` → ``Param(("cn",), [cn, value])``.
66
+
67
+ Default ``0.0`` — broadcast to every constraint via the source.
68
+
69
+ The constraint axis uses the column name ``cn`` (not ``c``) to avoid
70
+ collision with the commodity axis — see
71
+ ``schemas/flextool_axis_contract.json`` review note ``c_collision``.
72
+ """
73
+ df = source.parameter("constraint", "constant")
74
+ if df.height == 0:
75
+ return None
76
+ return Param(("cn",),
77
+ df.lazy().rename({"name": "cn"}).select("cn", "value"))
78
+
79
+
80
+ def constraint_sense_from_source(source: "InputSource") -> pl.DataFrame:
81
+ """Return the constraint sense set as ``[cn, sense]``. Empty frame
82
+ if no constraints carry a ``sense`` value (None-default policy).
83
+ """
84
+ df = source.parameter("constraint", "sense")
85
+ if df.height == 0:
86
+ return pl.DataFrame(schema={"cn": pl.Utf8, "sense": pl.Utf8})
87
+ return df.lazy().rename({"name": "cn", "value": "sense"}).select("cn", "sense").collect()
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # §5.2.7 — method discriminator scalars (string)
92
+
93
+
94
+ def node_node_type_from_source(source: "InputSource") -> pl.DataFrame:
95
+ """``node.node_type`` → ``[n, value]`` with ``balance`` default
96
+ broadcast to every node.
97
+ """
98
+ df = source.parameter("node", "node_type")
99
+ if df.height == 0:
100
+ return pl.DataFrame(schema={"n": pl.Utf8, "value": pl.Utf8})
101
+ return df.lazy().rename({"name": "n"}).select("n", "value").collect()
102
+
103
+
104
+ def node_inflow_method_from_source(source: "InputSource") -> pl.DataFrame:
105
+ """``node.inflow_method`` → ``[n, value]``. None default — only
106
+ nodes with an explicit inflow_method are returned.
107
+ """
108
+ df = source.parameter("node", "inflow_method")
109
+ if df.height == 0:
110
+ return pl.DataFrame(schema={"n": pl.Utf8, "value": pl.Utf8})
111
+ return df.lazy().rename({"name": "n"}).select("n", "value").collect()
112
+
113
+
114
+ def unit_conversion_method_from_source(source: "InputSource") -> pl.DataFrame:
115
+ """``unit.conversion_method`` → ``[p, value]``. None default."""
116
+ df = source.parameter("unit", "conversion_method")
117
+ if df.height == 0:
118
+ return pl.DataFrame(schema={"p": pl.Utf8, "value": pl.Utf8})
119
+ return df.lazy().rename({"name": "p"}).select("p", "value").collect()
120
+
121
+
122
+ def connection_transfer_method_from_source(source: "InputSource") -> pl.DataFrame:
123
+ """``connection.transfer_method`` → ``[p, value]``. None default."""
124
+ df = source.parameter("connection", "transfer_method")
125
+ if df.height == 0:
126
+ return pl.DataFrame(schema={"p": pl.Utf8, "value": pl.Utf8})
127
+ return df.lazy().rename({"name": "p"}).select("p", "value").collect()
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # §5.2.1 — penalty (sentinel default ⇒ broadcast)
132
+
133
+
134
+ def penalty_up_scalar_from_source(source: "InputSource") -> pl.DataFrame:
135
+ """``node.penalty_up`` per-node scalar (pre-broadcast over (d, t)).
136
+
137
+ The sentinel default (large positive value, e.g. 10000.0) is
138
+ applied at the source level (§4.6). The downstream Derived helper
139
+ cross-joins this against ``(d, t)`` to produce ``p_penalty_up``.
140
+ """
141
+ df = source.parameter("node", "penalty_up")
142
+ return df.lazy().rename({"name": "n"}).select("n", "value").collect()
143
+
144
+
145
+ def penalty_down_scalar_from_source(source: "InputSource") -> pl.DataFrame:
146
+ df = source.parameter("node", "penalty_down")
147
+ return df.lazy().rename({"name": "n"}).select("n", "value").collect()
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Existing capacity (object-class scalars, per-class then unioned for `e`)
152
+
153
+
154
+ def _union_existing(source: "InputSource", classes: list[str]) -> pl.DataFrame:
155
+ """Union ``existing`` across the supplied entity classes into one
156
+ long ``[e, value]`` frame. Returns an empty frame if no class
157
+ contributes a row.
158
+ """
159
+ parts: list[pl.LazyFrame] = []
160
+ for cls in classes:
161
+ try:
162
+ df = source.parameter(cls, "existing")
163
+ except KeyError:
164
+ continue
165
+ if df.height == 0:
166
+ continue
167
+ parts.append(df.lazy().rename({"name": "e"}).select("e", "value"))
168
+ if not parts:
169
+ return pl.DataFrame(schema={"e": pl.Utf8, "value": pl.Float64})
170
+ return pl.concat(parts).collect().sort("e")
171
+
172
+
173
+ def existing_long_from_source(source: "InputSource") -> pl.DataFrame:
174
+ """Per-(entity, value) ``existing`` across unit/node/connection.
175
+ Mirrors flextool's preprocessed ``p_entity_unitsize.csv`` indirectly
176
+ — but here we expose just the raw scalar.
177
+ """
178
+ return _union_existing(source, ["unit", "node", "connection"])
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Membership relationships (entities-only, no parameter)
183
+
184
+
185
+ def commodity_node_set_from_source(source: "InputSource") -> pl.DataFrame:
186
+ """``commodity__node`` membership set. Schema: ``[commodity, node]``."""
187
+ return source.entities("commodity__node")
188
+
189
+
190
+ def unit_input_node_set_from_source(source: "InputSource") -> pl.DataFrame:
191
+ """``unit__inputNode`` membership set. Schema: ``[unit, node]``."""
192
+ return source.entities("unit__inputNode")
193
+
194
+
195
+ def unit_output_node_set_from_source(source: "InputSource") -> pl.DataFrame:
196
+ """``unit__outputNode`` membership set. Schema: ``[unit, node]``."""
197
+ return source.entities("unit__outputNode")
198
+
199
+
200
+ def connection_node_node_set_from_source(source: "InputSource") -> pl.DataFrame:
201
+ """``connection__node__node`` membership set. Schema:
202
+ ``[connection, node_1, node_2]`` (the dim suffix disambiguates the
203
+ repeated ``node`` dim class).
204
+ """
205
+ return source.entities("connection__node__node")
206
+
207
+
208
+ def reserve_upDown_group_set_from_source(source: "InputSource") -> pl.DataFrame:
209
+ """``reserve__upDown__group`` membership set. Schema:
210
+ ``[reserve, upDown, group]``.
211
+ """
212
+ return source.entities("reserve__upDown__group")
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # §5.2.3 — relationship 1d_map / scalar (conversion_flow_coeff, etc.)
217
+
218
+
219
+ def unit_input_conversion_flow_coeff_from_source(source: "InputSource") -> Param | None:
220
+ """``unit__inputNode.conversion_flow_coeff`` → ``Param(("p","source"), …)``.
221
+
222
+ Default 1.0 — broadcast over all (unit, node) entities.
223
+ """
224
+ df = source.parameter("unit__inputNode", "conversion_flow_coeff")
225
+ if df.height == 0:
226
+ return None
227
+ return Param(("p", "source"),
228
+ df.lazy().rename({"unit": "p", "node": "source"})
229
+ .select("p", "source", "value"))
230
+
231
+
232
+ def unit_output_conversion_flow_coeff_from_source(source: "InputSource") -> Param | None:
233
+ """``unit__outputNode.conversion_flow_coeff`` → ``Param(("p","sink"), …)``."""
234
+ df = source.parameter("unit__outputNode", "conversion_flow_coeff")
235
+ if df.height == 0:
236
+ return None
237
+ return Param(("p", "sink"),
238
+ df.lazy().rename({"unit": "p", "node": "sink"})
239
+ .select("p", "sink", "value"))
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Module catalog (used by the parity test scaffolding to iterate over
244
+ # the first-wave Params). The shape of each entry is:
245
+ #
246
+ # (logical_name, callable_returning_frame, fixture_csv_extractor) where
247
+ #
248
+ # - logical_name: stable label for parametrized test ids.
249
+ # - callable_returning_frame: takes an InputSource → frame.
250
+ # - fixture_csv_extractor: takes the CSV-loaded ``FlexData`` →
251
+ # the corresponding frame (or None) for parity comparison.
252
+ #
253
+ # Only entries whose CSV-side has a clean structural mirror are listed
254
+ # here. Helpers without a direct ``FlexData`` field (e.g. method
255
+ # discriminator strings consumed by Projection) are exercised by the
256
+ # unit-test suite via :class:`InMemoryReader` instead.
257
+
258
+ # Imported lazily inside the test module to avoid a circular import.
259
+
260
+ FIRST_WAVE_PARAMS = (
261
+ "p_co2_content",
262
+ "p_constraint_constant",
263
+ "node_type",
264
+ "inflow_method",
265
+ "conversion_method",
266
+ "transfer_method",
267
+ "penalty_up",
268
+ "penalty_down",
269
+ "commodity_node_set",
270
+ "unit_inputNode_set",
271
+ "unit_outputNode_set",
272
+ "connection_node_node_set",
273
+ "reserve_upDown_group_set",
274
+ "unit_input_flow_coef",
275
+ "unit_output_flow_coef",
276
+ )
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Helpers for FlexData-field-targeted overrides
281
+
282
+
283
+ # Accepted ``Map.index_name`` labels for the per-entity constraint-coef
284
+ # parameters. ``"constraint"`` is what ``model_builder`` writes; ``"x"``
285
+ # is spinedb_api's default when a Map is authored without an explicit
286
+ # ``index_name``. Anything else indicates a Spine authoring mistake
287
+ # (typo, wrong axis label) and is surfaced loudly with the offending
288
+ # entity rows so the user can fix the DB.
289
+ _CONSTRAINT_INDEX_LABELS = ("constraint", "x")
290
+
291
+
292
+ def _resolve_constraint_index_col(
293
+ df: pl.DataFrame,
294
+ *,
295
+ parameter_name: str,
296
+ entity_class: str,
297
+ ) -> str:
298
+ """Identify the Map's constraint-axis column in *df* (the frame
299
+ returned by :meth:`InputSource.parameter`).
300
+
301
+ Raises a clear ``ValueError`` naming the parameter, entity_class,
302
+ offending column name, and the entity rows that carry the bad Map
303
+ so the user can locate and fix it in Spine.
304
+ """
305
+ extras = [c for c in df.columns if c not in ("name", "value")]
306
+ if len(extras) == 1 and extras[0] in _CONSTRAINT_INDEX_LABELS:
307
+ return extras[0]
308
+ accepted = ", ".join(repr(s) for s in _CONSTRAINT_INDEX_LABELS)
309
+ entities = df.get_column("name").unique().to_list() if "name" in df.columns else []
310
+ entities_repr = ", ".join(repr(e) for e in entities[:10])
311
+ if len(entities) > 10:
312
+ entities_repr += f", … (+{len(entities) - 10} more)"
313
+ raise ValueError(
314
+ f"Unrecognised Map index column(s) {extras!r} in parameter "
315
+ f"{parameter_name!r} of entity_class {entity_class!r} "
316
+ f"(affected entities: [{entities_repr}]). "
317
+ f"Expected a 1-D Map with index_name in {{{accepted}}}; "
318
+ f"set Map.index_name to 'constraint' in Spine."
319
+ )
320
+
321
+
322
+ def _node_constraint_coef(source: "InputSource", parameter_name: str) -> Param | None:
323
+ """Return ``Param(("n", "cn"), [n, cn, value])`` for a per-(node,
324
+ constraint) coefficient parameter. ``None`` if the DB has no rows.
325
+
326
+ The constraint axis column is named ``cn`` to disambiguate from the
327
+ commodity axis column ``c`` (the contract's ``c_collision`` decision).
328
+ """
329
+ try:
330
+ df = source.parameter("node", parameter_name)
331
+ except KeyError:
332
+ # Older DBs may not define the parameter on the ``node`` class
333
+ # at all (rather than just having zero rows). Treat the same.
334
+ return None
335
+ if df.height == 0:
336
+ return None
337
+ c_col = _resolve_constraint_index_col(
338
+ df, parameter_name=parameter_name, entity_class="node")
339
+ return Param(
340
+ ("n", "cn"),
341
+ df.lazy()
342
+ .rename({"name": "n", c_col: "cn"})
343
+ .select("n", "cn", "value")
344
+ )
345
+
346
+
347
+ def _process_constraint_coef(source: "InputSource",
348
+ parameter_name: str) -> Param | None:
349
+ """Union the per-(process, constraint) coefficient across the unit +
350
+ connection classes (the ``process`` superclass in flextool's CSV
351
+ output). ``None`` if neither contributes a row.
352
+ """
353
+ parts: list[pl.LazyFrame] = []
354
+ for cls in ("unit", "connection"):
355
+ try:
356
+ df = source.parameter(cls, parameter_name)
357
+ except KeyError:
358
+ continue
359
+ if df.height == 0:
360
+ continue
361
+ c_col = _resolve_constraint_index_col(
362
+ df, parameter_name=parameter_name, entity_class=cls)
363
+ parts.append(
364
+ df.lazy()
365
+ .rename({"name": "p", c_col: "cn"})
366
+ .select("p", "cn", "value")
367
+ )
368
+ if not parts:
369
+ return None
370
+ return Param(("p", "cn"), pl.concat(parts).sort("p", "cn"))
371
+
372
+
373
+ def _entity_methods_pairs(source: "InputSource") -> set[tuple[str, str]]:
374
+ """Mirror ``entity__invest_method.csv`` — return ``{(e, method)}``.
375
+
376
+ Spine source: ``unit/node/connection.invest_method``. Empty when
377
+ no entity has an explicit method.
378
+ """
379
+ out: set[tuple[str, str]] = set()
380
+ for cls in ("unit", "node", "connection"):
381
+ try:
382
+ df = source.parameter(cls, "invest_method")
383
+ except KeyError:
384
+ continue
385
+ if df.height == 0:
386
+ continue
387
+ for e, m in df.select("name", "value").iter_rows():
388
+ out.add((str(e), str(m)))
389
+ return out
390
+
391
+
392
+ # Mirror ``invest_method_sets.py:22-27``.
393
+ _INVEST_METHOD_NOT_ALLOWED = frozenset((
394
+ "not_allowed", "retire_period", "retire_total", "retire_no_limit",
395
+ ))
396
+ _DIVEST_METHOD_NOT_ALLOWED = frozenset((
397
+ "not_allowed", "invest_period", "invest_total", "invest_no_limit",
398
+ ))
399
+
400
+
401
+ def _entity_invest_universe(source: "InputSource",
402
+ kind: str = "invest") -> list[str]:
403
+ """Return ``entityInvest`` (or ``entityDivest``) — entities whose
404
+ invest_method is NOT in the not-allowed set. Ordered by class
405
+ encounter (unit → node → connection), deduplicated, matching
406
+ ``invest_method_sets.py:30-46``.
407
+ """
408
+ not_allowed = (_INVEST_METHOD_NOT_ALLOWED if kind == "invest"
409
+ else _DIVEST_METHOD_NOT_ALLOWED)
410
+ seen: dict[str, None] = {}
411
+ for cls in ("unit", "node", "connection"):
412
+ try:
413
+ df = source.parameter(cls, "invest_method")
414
+ except KeyError:
415
+ continue
416
+ if df.height == 0:
417
+ continue
418
+ for e, m in df.select("name", "value").iter_rows():
419
+ if str(m) not in not_allowed:
420
+ seen.setdefault(str(e), None)
421
+ return list(seen.keys())
422
+
423
+
424
+ def _e_total_param(source: "InputSource", parameter_name: str,
425
+ kind: str = "invest",
426
+ filter_zero: bool = False) -> Param | None:
427
+ """Per-entity scalar parameter (e.g. ``invest_max_total``) keyed on
428
+ ``entityInvest`` (or ``entityDivest`` for divest variants), with 0
429
+ default for entities with no explicit row.
430
+
431
+ Mirrors flextool's ``entity_total_caps.py:_compute_entity_total`` —
432
+ a row is emitted for every entity in ``entityInvest`` (resp.
433
+ ``entityDivest``), value defaults to 0 when neither
434
+ ``unit/node/connection`` carries an explicit row.
435
+
436
+ When ``filter_zero=True``, rows with ``value == 0`` are dropped
437
+ (mirrors ``input.py::_read_e_param`` for ``invest_min_total`` /
438
+ ``retire_min_total`` which use a different reader that filters
439
+ zeros). Returns ``None`` when no rows survive (or
440
+ ``entityInvest`` is empty).
441
+ """
442
+ keys = _entity_invest_universe(source, kind=kind)
443
+ if not keys:
444
+ return None
445
+ explicit: dict[str, float] = {}
446
+ for cls in ("unit", "node", "connection"):
447
+ try:
448
+ df = source.parameter_explicit(cls, parameter_name)
449
+ except (KeyError, AttributeError):
450
+ try:
451
+ df = source.parameter(cls, parameter_name)
452
+ except KeyError:
453
+ continue
454
+ if df is None or df.height == 0:
455
+ continue
456
+ for e, v in df.select("name", "value").iter_rows():
457
+ explicit[str(e)] = float(v) if v is not None else 0.0
458
+ rows = [(e, explicit.get(e, 0.0)) for e in keys]
459
+ if filter_zero:
460
+ rows = [(e, v) for e, v in rows if v != 0.0]
461
+ if not rows:
462
+ return None
463
+ out = pl.DataFrame(rows, schema=["e", "value"], orient="row").sort("e")
464
+ return Param(("e",), out)
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Δ.4 — second-wave Direct Param helpers.
469
+ #
470
+ # Each helper below covers a single FlexData field whose CSV-loader path
471
+ # in ``input.py`` is a one-CSV ``rename + select`` of an entity scalar /
472
+ # Map / 1d_map parameter. Helpers return ``Param | None`` (None when
473
+ # the DB-side has no explicit row, matching the CSV path's "absent →
474
+ # None" contract). The CSV-side behaviour preserved verbatim — the
475
+ # parity tests must continue to pass.
476
+
477
+ def _entity_scalar_explicit(source: "InputSource", entity_class: str,
478
+ parameter_name: str,
479
+ dim: str) -> Param | None:
480
+ """Return ``Param((dim,), [<dim>, value])`` for an entity-class
481
+ scalar, mirroring the CSV path's "explicit rows only" semantics.
482
+
483
+ Tolerates a Spine schema that doesn't declare the parameter on the
484
+ class (older fixtures): ``KeyError`` from
485
+ :meth:`InputSource.parameter_explicit` / :meth:`parameter` is
486
+ treated as "no rows".
487
+ """
488
+ try:
489
+ df = source.parameter_explicit(entity_class, parameter_name)
490
+ except (KeyError, AttributeError):
491
+ try:
492
+ df = source.parameter(entity_class, parameter_name)
493
+ except KeyError:
494
+ return None
495
+ if df is None or df.height == 0:
496
+ return None
497
+ return Param((dim,),
498
+ df.lazy().rename({"name": dim}).select(dim, "value"))
499
+
500
+
501
+ # §5.2.5 — node scalars sliced from p_node.csv
502
+ def p_state_self_discharge_from_source(source: "InputSource") -> Param | None:
503
+ """``node.self_discharge_loss`` → ``Param(("n",), [n, value])``.
504
+
505
+ Default 0.0 (schema). CSV path
506
+ (``input.py::_load_storage::_node_param``) returns the explicit
507
+ rows only. We mirror by reading via ``parameter_explicit``.
508
+ """
509
+ return _entity_scalar_explicit(source, "node", "self_discharge_loss", "n")
510
+
511
+
512
+ def p_state_start_from_source(source: "InputSource") -> Param | None:
513
+ """``node.storage_state_start`` → ``Param(("n",), [n, value])``.
514
+
515
+ Default ``None`` (schema). Returns explicit rows only.
516
+ """
517
+ return _entity_scalar_explicit(source, "node", "storage_state_start", "n")
518
+
519
+
520
+ # §5.2.7 — process scalars sliced from p_process.csv
521
+ def p_min_load_from_source(source: "InputSource") -> Param | None:
522
+ """``unit.min_load`` → ``Param(("p",), [p, value])``.
523
+
524
+ Default 0.0 (schema). CSV path filters by processParam=='min_load'
525
+ and emits rows only when explicit; we mirror via ``parameter_explicit``.
526
+ """
527
+ return _entity_scalar_explicit(source, "unit", "min_load", "p")
528
+
529
+
530
+ # §5.18 — connection scalars
531
+ def p_connection_susceptance_from_source(source: "InputSource") -> Param | None:
532
+ """``connection.susceptance`` → ``Param(("p",), [p, value])``.
533
+
534
+ Default ``None``. Used only for DC-power-flow scenarios. Older
535
+ fixtures may not define ``susceptance`` on the class — treated as
536
+ "no rows".
537
+ """
538
+ return _entity_scalar_explicit(source, "connection", "susceptance", "p")
539
+
540
+
541
+ # §5.19 — commodity scalars
542
+ def p_commodity_unitsize_from_source(source: "InputSource") -> Param | None:
543
+ """``commodity.unitsize`` → ``Param(("c",), [c, value])``.
544
+
545
+ Default 1.0 (schema). Used by the commodity-price-ladder feature
546
+ only. CSV path emits explicit rows only — mirror that via
547
+ ``parameter_explicit``.
548
+ """
549
+ return _entity_scalar_explicit(source, "commodity", "unitsize", "c")
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # Δ.4b — third wave Direct Param helpers.
554
+ #
555
+ # Patterns covered below:
556
+ #
557
+ # 1. Indexed (1d_map) entity-class scalars — e.g. ``unit.invest_max_period``
558
+ # (``ed_invest_max_period``) which unrolls to ``[entity, period, value]``.
559
+ # 2. Indexed Map(period→time) parameters — e.g. ``node.availability``
560
+ # (``p_node_availability``) which unrolls to ``[entity, period, t,
561
+ # value]``.
562
+ # 3. Per-(entity, side) relationship scalars — e.g.
563
+ # ``unit__inputNode.ramp_speed_up`` (``p_ramp_speed_up_source``).
564
+ # 4. Multi-class union 1d_map — e.g. invest/divest period caps spanning
565
+ # ``unit`` ∪ ``node`` ∪ ``connection``.
566
+ # 5. Multi-class union Map — e.g. ``other_operational_cost`` for var-cost.
567
+ #
568
+ # All helpers honour the CSV path's "drop zero rows" + "explicit rows
569
+ # only" semantics — failing loudly if the underlying parameter shape
570
+ # doesn't match the expected schema.
571
+
572
+ def _filter_param_by_periods(p: Param | None,
573
+ period_filter: pl.DataFrame | None
574
+ ) -> Param | None:
575
+ """Restrict ``p`` to rows whose ``d`` (and optionally ``t``) column
576
+ matches the active solve's ``period_filter``. Returns the Param
577
+ with the join chained onto its existing LazyFrame — no eager
578
+ collect. Returns ``p`` unchanged when there's nothing to filter
579
+ on (``p`` is None, filter is empty, or the Param has no ``d``/
580
+ ``t`` axes to restrict).
581
+
582
+ Shape-aware (Phase E.1): the join is on whichever of ``d`` / ``t``
583
+ are present in ``p.dims``. Under the lazified broadcast contract
584
+ a MAP_TIME-authored field is ``(entity, t)`` and a SCALAR-authored
585
+ field is ``(entity,)``; both are handled — the former joins on
586
+ ``t``, the latter passes through unchanged.
587
+
588
+ Mirrors the CSV path's ``solve_data/pdtNode.csv inner-join with
589
+ steps_in_use`` semantic when both ``d`` and ``t`` are present.
590
+
591
+ Reads ``p.lazy`` directly — does NOT trigger ``polar_high.Param``'s
592
+ eager ``.frame`` cache.
593
+ """
594
+ if p is None or period_filter is None or period_filter.height == 0:
595
+ return p
596
+ p_dims = set(p.dims)
597
+ has_d = "d" in p_dims
598
+ has_t = "t" in p_dims
599
+ if not has_d and not has_t:
600
+ # SCALAR-shape Param (e.g. ``(entity,)``) — nothing to filter on.
601
+ return p
602
+ # Phase 4.8g: the Param's underlying LazyFrame may carry Utf8
603
+ # ``d`` / ``t`` columns from the Direct-Param CSV/scalar path while
604
+ # ``period_filter`` (the ``dt`` frame) is fully Enum-cast under
605
+ # activation. Defensively cast both sides to the live axis enums
606
+ # so the join keys match — without this the join raises
607
+ # ``SchemaError`` (Utf8 ↔ Enum).
608
+ lf = p.lazy
609
+ _enums = get_global_axis_enums()
610
+ if _enums is not None:
611
+ lf = cast_frame_axes(lf, _enums)
612
+ period_filter = cast_frame_axes(period_filter, _enums)
613
+ pf_cols = set(period_filter.columns)
614
+ if has_d and has_t and {"d", "t"}.issubset(pf_cols):
615
+ keep = period_filter.lazy().select("d", "t").unique()
616
+ out_lf = lf.join(keep, on=["d", "t"], how="inner")
617
+ elif has_d and "d" in pf_cols:
618
+ keep = period_filter.lazy().select("d").unique()
619
+ out_lf = lf.join(keep, on="d", how="inner")
620
+ elif has_t and "t" in pf_cols:
621
+ # Phase E.1: MAP_TIME-authored field is ``(entity, t)`` — no
622
+ # ``d`` to filter on. Restrict by ``t`` instead.
623
+ keep = period_filter.lazy().select("t").unique()
624
+ out_lf = lf.join(keep, on="t", how="inner")
625
+ else:
626
+ # Param has a d/t axis but the period_filter doesn't carry the
627
+ # matching column — nothing to filter on.
628
+ return p
629
+ return Param(p.dims, out_lf)
630
+
631
+
632
+ def _entity_period_scalar(source: "InputSource", entity_class: str,
633
+ parameter_name: str,
634
+ entity_dim: str,
635
+ *,
636
+ filter_zero: bool = False,
637
+ filter_null: bool = True,
638
+ period_filter: pl.DataFrame | None = None
639
+ ) -> Param | None:
640
+ """Return ``Param((entity_dim, "d"), [<entity_dim>, d, value])`` for a
641
+ ``1d_map(period)`` OR scalar parameter on the given entity class.
642
+
643
+ Mirrors the CSV path's slice of ``pdGroup.csv`` / ``pdProcess.csv``
644
+ style files: explicit rows only, optionally filtering out zero /
645
+ null values. Returns ``None`` when no rows survive.
646
+
647
+ Δ.12c-fix gap #3: scalar broadcast. When the source returns a
648
+ scalar shape (``[name, value]`` without a ``period`` column) we
649
+ broadcast the scalar over the periods supplied in ``period_filter``
650
+ — mirroring flextool's preprocessing which expands a group/process
651
+ scalar to one row per (entity, period) before writing the
652
+ ``pdGroup.csv`` / ``pd_process.csv`` family. Without
653
+ ``period_filter`` we cannot infer the broadcast axis and fall
654
+ through to ``None``.
655
+
656
+ ``period_filter``: optional ``[d]`` frame restricting the output to
657
+ a subset of periods (mirrors flextool preprocessing's per-solve
658
+ period filter — Spine ``invest_max_period`` / ``co2_max_period``
659
+ Maps cover ALL declared periods, but the CSV path's
660
+ ``pd_group.csv`` etc. is pre-filtered to the active solve's
661
+ periods). Pass ``flex_data.dt`` (already restricted to the active
662
+ solve) to mirror that semantic. Also used as the broadcast axis
663
+ for scalar-shape values.
664
+ """
665
+ try:
666
+ df = source.parameter_explicit(entity_class, parameter_name)
667
+ except (KeyError, AttributeError):
668
+ try:
669
+ df = source.parameter(entity_class, parameter_name)
670
+ except KeyError:
671
+ return None
672
+ if df is None or df.height == 0:
673
+ return None
674
+ cols = df.columns
675
+ if "value" not in cols:
676
+ return None
677
+ if "period" not in cols:
678
+ # Scalar broadcast cascade — Δ.12c-fix gap #3. Mirrors
679
+ # flextool preprocessing which writes one (entity, period, value)
680
+ # row per period when the source value is a scalar.
681
+ if period_filter is None or period_filter.height == 0:
682
+ return None
683
+ if "name" not in cols:
684
+ return None
685
+ lf = df.lazy().pipe(rename_to_axis, {"name": entity_dim})
686
+ if filter_null:
687
+ lf = lf.filter(pl.col("value").is_not_null())
688
+ if filter_zero:
689
+ lf = lf.filter(pl.col("value") != 0.0)
690
+ periods = period_filter.lazy().select("d").unique()
691
+ out = (lf.select(entity_dim, "value")
692
+ .join(periods, how="cross")
693
+ .select(entity_dim, "d", "value")
694
+ .collect())
695
+ if out.height == 0:
696
+ return None
697
+ return Param((entity_dim, "d"), out.lazy())
698
+ lf = df.lazy().pipe(rename_to_axis, {"name": entity_dim, "period": "d"})
699
+ if filter_null:
700
+ lf = lf.filter(pl.col("value").is_not_null())
701
+ if filter_zero:
702
+ lf = lf.filter(pl.col("value") != 0.0)
703
+ if period_filter is not None and period_filter.height > 0:
704
+ lf = lf.join(period_filter.lazy().select("d").unique(), on="d",
705
+ how="inner")
706
+ out = lf.select(entity_dim, "d", "value").collect()
707
+ if out.height == 0:
708
+ return None
709
+ return Param((entity_dim, "d"), out.lazy())
710
+
711
+
712
+ def _entity_period_time_param(source: "InputSource", entity_class: str,
713
+ parameter_name: str,
714
+ entity_dim: str,
715
+ *,
716
+ filter_zero: bool = False,
717
+ period_filter: pl.DataFrame | None = None
718
+ ) -> Param | None:
719
+ """Return ``Param((entity_dim, "d", "t"), [...])`` for a
720
+ ``Map(period→time)`` parameter. CSV slice of ``pdtNode.csv`` /
721
+ ``pdtCommodity.csv`` / ``pdtGroup.csv`` files.
722
+
723
+ ``period_filter`` restricts the output to a subset of periods (per
724
+ the active solve), mirroring CSV preprocessing.
725
+
726
+ Δ.12c-fix gap #1 — broadcast cascade. In addition to the explicit
727
+ ``Map(period→time)`` shape (columns ``[name, period, t, value]``)
728
+ the helper now broadcasts:
729
+ * 1d_map(time) — ``[name, t, value]`` → cross-join with periods.
730
+ * 1d_map(period) — ``[name, period, value]`` → cross-join with t.
731
+ * scalar — ``[name, value]`` → cross-join with (period, t).
732
+ Mirrors flextool preprocessing which writes one row per (entity,
733
+ period, t) into the ``pdtX.csv`` family.
734
+
735
+ Broadcast requires ``period_filter`` to carry the (d, t) pairs to
736
+ expand against (typically the active solve's ``dt`` frame). Without
737
+ it the broadcast paths fall through to ``None``.
738
+ """
739
+ try:
740
+ df = source.parameter_explicit(entity_class, parameter_name)
741
+ except (KeyError, AttributeError):
742
+ try:
743
+ df = source.parameter(entity_class, parameter_name)
744
+ except KeyError:
745
+ return None
746
+ if df is None or df.height == 0:
747
+ return None
748
+ cols = df.columns
749
+ if "value" not in cols or "name" not in cols:
750
+ return None
751
+ has_period = "period" in cols
752
+ has_t = "t" in cols
753
+ if has_period and has_t:
754
+ lf = (df.lazy()
755
+ .pipe(rename_to_axis, {"name": entity_dim, "period": "d"})
756
+ .filter(pl.col("value").is_not_null()))
757
+ if filter_zero:
758
+ lf = lf.filter(pl.col("value") != 0.0)
759
+ if period_filter is not None and period_filter.height > 0:
760
+ lf = lf.join(period_filter.lazy().select("d").unique(), on="d",
761
+ how="inner")
762
+ out = lf.select(entity_dim, "d", "t", "value").collect()
763
+ if out.height == 0:
764
+ return None
765
+ return Param((entity_dim, "d", "t"), out.lazy())
766
+ # Broadcast paths require period_filter to know the (d, t) axis.
767
+ if period_filter is None or period_filter.height == 0:
768
+ return None
769
+ pf_cols = set(period_filter.columns)
770
+ if not {"d", "t"}.issubset(pf_cols):
771
+ return None
772
+ lf = df.lazy().pipe(rename_to_axis, {"name": entity_dim}).filter(
773
+ pl.col("value").is_not_null())
774
+ if filter_zero:
775
+ lf = lf.filter(pl.col("value") != 0.0)
776
+ dt_lf = period_filter.lazy().select("d", "t").unique()
777
+ if has_period:
778
+ # 1d_map(period) → broadcast across t per (entity, d).
779
+ lf2 = lf.pipe(rename_to_axis, {"period": "d"}).select(entity_dim, "d", "value")
780
+ out = (lf2.join(dt_lf, on="d", how="inner")
781
+ .select(entity_dim, "d", "t", "value")
782
+ .collect())
783
+ elif has_t:
784
+ # 1d_map(time) → broadcast across d per (entity, t).
785
+ lf2 = lf.select(entity_dim, "t", "value")
786
+ out = (lf2.join(dt_lf, on="t", how="inner")
787
+ .select(entity_dim, "d", "t", "value")
788
+ .collect())
789
+ else:
790
+ # Scalar → broadcast across (d, t) per entity.
791
+ lf2 = lf.select(entity_dim, "value")
792
+ out = (lf2.join(dt_lf, how="cross")
793
+ .select(entity_dim, "d", "t", "value")
794
+ .collect())
795
+ if out.height == 0:
796
+ return None
797
+ return Param((entity_dim, "d", "t"), out.lazy())
798
+
799
+
800
+ # §5.14 — group scalars sliced from input/p_group.csv
801
+ def _g_scalar(source: "InputSource", parameter_name: str,
802
+ *, filter_zero: bool = True,
803
+ entity_class: str = "group") -> Param | None:
804
+ """Group scalar Param ``Param(("g",), [g, value])``, dropping zero
805
+ rows by default (mirrors ``_slice_pgroup``'s ``!= 0`` filter).
806
+
807
+ ``entity_class`` defaults to ``"group"`` (all non-flow callers); the
808
+ v58 cumulative-flow callers pass ``"flowGroup"`` since those four
809
+ flow params now live on the dedicated ``flowGroup`` class.
810
+ """
811
+ try:
812
+ df = source.parameter_explicit(entity_class, parameter_name)
813
+ except (KeyError, AttributeError):
814
+ try:
815
+ df = source.parameter(entity_class, parameter_name)
816
+ except KeyError:
817
+ return None
818
+ if df is None or df.height == 0:
819
+ return None
820
+ if "value" not in df.columns:
821
+ return None
822
+ lf = df.lazy().pipe(rename_to_axis, {"name": "g"}).filter(pl.col("value").is_not_null())
823
+ if filter_zero:
824
+ lf = lf.filter(pl.col("value") != 0.0)
825
+ # Indexed (period) shapes don't belong here — guard.
826
+ if "period" in df.columns or "t" in df.columns:
827
+ return None
828
+ out = lf.select("g", "value").collect()
829
+ if out.height == 0:
830
+ return None
831
+ return Param(("g",), out.lazy())
832
+
833
+
834
+ # §1.16 — pdGroup_* (1d_map period) — dropping zeros and nulls
835
+ #
836
+ # Δ.17c-Tier1 — silent-default index migration. These helpers were
837
+ # previously routed through ``_entity_period_scalar``, which keyed the
838
+ # scalar/explicit branch off ``"period" not in cols``. When the source
839
+ # DB authored the Map without ``index_name`` (spinedb_api's silent
840
+ # default ``"x"``), the helper fell into the scalar branch and
841
+ # cross-joined the Map's rows against the period filter — producing
842
+ # ``N_map_rows × N_periods`` duplicate (g, d) keys per entity and
843
+ # destroying the period → value association.
844
+ #
845
+ # Migration template: ``resolve_param_shape`` reads the DB-side shape
846
+ # (with silent-default disambiguation via ``_infer_silent_default_labels``
847
+ # — the {SCALAR, MAP_PERIOD} allow-list resolves the silent default
848
+ # structurally at depth 1) and ``broadcast_to_period`` produces the
849
+ # (entity, d) Param. ``filter_zero=True`` is supported natively by
850
+ # ``broadcast_to_period``, mirroring the legacy
851
+ # ``filter_null=True, filter_zero=True`` semantic.
852
+ def pdGroup_capacity_margin_from_source(source: "InputSource",
853
+ period_filter: pl.DataFrame | None = None,
854
+ ) -> Param | None:
855
+ """``group.capacity_margin`` → ``Param(("g","d"))``.
856
+
857
+ Δ.17c-Tier1 — uses :func:`._param_shapes.resolve_param_shape`.
858
+ Allowed shapes: scalar / 1d_map[period]. CSV path drops zero
859
+ rows; we mirror via ``filter_zero=True``.
860
+ """
861
+ resolved = resolve_param_shape(
862
+ source, "group", "capacity_margin", period_filter=period_filter)
863
+ return broadcast_to_period(
864
+ resolved, "g", period_filter, filter_zero=True)
865
+
866
+
867
+ def pdGroup_penalty_capacity_margin_from_source(source: "InputSource",
868
+ period_filter: pl.DataFrame | None = None,
869
+ ) -> Param | None:
870
+ """``group.penalty_capacity_margin`` → ``Param(("g","d"))``.
871
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
872
+ """
873
+ resolved = resolve_param_shape(
874
+ source, "group", "penalty_capacity_margin",
875
+ period_filter=period_filter)
876
+ return broadcast_to_period(
877
+ resolved, "g", period_filter, filter_zero=True)
878
+
879
+
880
+ def pdGroup_inertia_limit_from_source(source: "InputSource",
881
+ period_filter: pl.DataFrame | None = None,
882
+ ) -> Param | None:
883
+ """``group.inertia_limit`` → ``Param(("g","d"))``.
884
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
885
+ """
886
+ resolved = resolve_param_shape(
887
+ source, "group", "inertia_limit", period_filter=period_filter)
888
+ return broadcast_to_period(
889
+ resolved, "g", period_filter, filter_zero=True)
890
+
891
+
892
+ def pdGroup_penalty_inertia_from_source(source: "InputSource",
893
+ period_filter: pl.DataFrame | None = None,
894
+ ) -> Param | None:
895
+ """``group.penalty_inertia`` → ``Param(("g","d"))``.
896
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
897
+ """
898
+ resolved = resolve_param_shape(
899
+ source, "group", "penalty_inertia", period_filter=period_filter)
900
+ return broadcast_to_period(
901
+ resolved, "g", period_filter, filter_zero=True)
902
+
903
+
904
+ def pdGroup_non_synchronous_limit_from_source(source: "InputSource",
905
+ period_filter: pl.DataFrame | None = None,
906
+ ) -> Param | None:
907
+ """``group.non_synchronous_limit`` → ``Param(("g","d"))``.
908
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
909
+ """
910
+ resolved = resolve_param_shape(
911
+ source, "group", "non_synchronous_limit",
912
+ period_filter=period_filter)
913
+ return broadcast_to_period(
914
+ resolved, "g", period_filter, filter_zero=True)
915
+
916
+
917
+ def pdGroup_penalty_non_synchronous_from_source(source: "InputSource",
918
+ period_filter: pl.DataFrame | None = None,
919
+ ) -> Param | None:
920
+ """``group.penalty_non_synchronous`` → ``Param(("g","d"))``.
921
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
922
+ """
923
+ resolved = resolve_param_shape(
924
+ source, "group", "penalty_non_synchronous",
925
+ period_filter=period_filter)
926
+ return broadcast_to_period(
927
+ resolved, "g", period_filter, filter_zero=True)
928
+
929
+
930
+ # §1.16 — group invest/divest 1d_map(period) — drop zeros to match CSV
931
+ def p_group_invest_max_period_from_source(source: "InputSource",
932
+ period_filter: pl.DataFrame | None = None,
933
+ ) -> Param | None:
934
+ """``group.invest_max_period`` → ``Param(("g","d"))``.
935
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
936
+ """
937
+ resolved = resolve_param_shape(
938
+ source, "group", "invest_max_period", period_filter=period_filter)
939
+ return broadcast_to_period(
940
+ resolved, "g", period_filter, filter_zero=True)
941
+
942
+
943
+ def p_group_invest_min_period_from_source(source: "InputSource",
944
+ period_filter: pl.DataFrame | None = None,
945
+ ) -> Param | None:
946
+ """``group.invest_min_period`` → ``Param(("g","d"))``.
947
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
948
+ """
949
+ resolved = resolve_param_shape(
950
+ source, "group", "invest_min_period", period_filter=period_filter)
951
+ return broadcast_to_period(
952
+ resolved, "g", period_filter, filter_zero=True)
953
+
954
+
955
+ def p_group_retire_max_period_from_source(source: "InputSource",
956
+ period_filter: pl.DataFrame | None = None,
957
+ ) -> Param | None:
958
+ """``group.retire_max_period`` → ``Param(("g","d"))``.
959
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
960
+ """
961
+ resolved = resolve_param_shape(
962
+ source, "group", "retire_max_period", period_filter=period_filter)
963
+ return broadcast_to_period(
964
+ resolved, "g", period_filter, filter_zero=True)
965
+
966
+
967
+ def p_group_retire_min_period_from_source(source: "InputSource",
968
+ period_filter: pl.DataFrame | None = None,
969
+ ) -> Param | None:
970
+ """``group.retire_min_period`` → ``Param(("g","d"))``.
971
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
972
+ """
973
+ resolved = resolve_param_shape(
974
+ source, "group", "retire_min_period", period_filter=period_filter)
975
+ return broadcast_to_period(
976
+ resolved, "g", period_filter, filter_zero=True)
977
+
978
+
979
+ def pd_max_cumulative_flow_from_source(source: "InputSource",
980
+ period_filter: pl.DataFrame | None = None,
981
+ ) -> Param | None:
982
+ """``flowGroup.max_cumulative_flow`` → ``Param(("g","d"))``.
983
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
984
+ """
985
+ resolved = resolve_param_shape(
986
+ source, "flowGroup", "max_cumulative_flow",
987
+ period_filter=period_filter)
988
+ return broadcast_to_period(
989
+ resolved, "g", period_filter, filter_zero=True)
990
+
991
+
992
+ def pd_min_cumulative_flow_from_source(source: "InputSource",
993
+ period_filter: pl.DataFrame | None = None,
994
+ ) -> Param | None:
995
+ """``flowGroup.min_cumulative_flow`` → ``Param(("g","d"))``.
996
+ Δ.17c-Tier1 — uses ``resolve_param_shape`` + ``broadcast_to_period``.
997
+ """
998
+ resolved = resolve_param_shape(
999
+ source, "flowGroup", "min_cumulative_flow",
1000
+ period_filter=period_filter)
1001
+ return broadcast_to_period(
1002
+ resolved, "g", period_filter, filter_zero=True)
1003
+
1004
+
1005
+ # §1.16 — group scalar Direct Params (no period dimension)
1006
+ def p_group_invest_max_total_from_source(source: "InputSource") -> Param | None:
1007
+ return _g_scalar(source, "invest_max_total")
1008
+
1009
+
1010
+ def p_group_invest_min_total_from_source(source: "InputSource") -> Param | None:
1011
+ return _g_scalar(source, "invest_min_total")
1012
+
1013
+
1014
+ def p_group_retire_max_total_from_source(source: "InputSource") -> Param | None:
1015
+ return _g_scalar(source, "retire_max_total")
1016
+
1017
+
1018
+ def p_group_retire_min_total_from_source(source: "InputSource") -> Param | None:
1019
+ return _g_scalar(source, "retire_min_total")
1020
+
1021
+
1022
+ def p_group_invest_max_cumulative_from_source(source: "InputSource") -> Param | None:
1023
+ return _g_scalar(source, "invest_max_cumulative")
1024
+
1025
+
1026
+ def p_group_invest_min_cumulative_from_source(source: "InputSource") -> Param | None:
1027
+ return _g_scalar(source, "invest_min_cumulative")
1028
+
1029
+
1030
+ def p_group_max_cumulative_flow_from_source(source: "InputSource") -> Param | None:
1031
+ return _g_scalar(source, "max_cumulative_flow", entity_class="flowGroup")
1032
+
1033
+
1034
+ def p_group_min_cumulative_flow_from_source(source: "InputSource") -> Param | None:
1035
+ return _g_scalar(source, "min_cumulative_flow", entity_class="flowGroup")
1036
+
1037
+
1038
+ # §1.16 — pdtGroup (Map period→time) instant flow caps
1039
+ def pdt_max_instant_flow_from_source(source: "InputSource",
1040
+ period_filter: pl.DataFrame | None = None,
1041
+ ) -> Param | None:
1042
+ """``flowGroup.max_instant_flow`` → ``Param(("g", "d", "t"))``.
1043
+
1044
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1045
+ shapes: scalar / 1d_map[period] / 1d_map[time] / 2d_map[period,time].
1046
+ """
1047
+ resolved = resolve_param_shape(
1048
+ source, "flowGroup", "max_instant_flow", period_filter=period_filter)
1049
+ return broadcast_to_period_time(
1050
+ resolved, "g", period_filter, filter_zero=True)
1051
+
1052
+
1053
+ def pdt_min_instant_flow_from_source(source: "InputSource",
1054
+ period_filter: pl.DataFrame | None = None,
1055
+ ) -> Param | None:
1056
+ """``flowGroup.min_instant_flow`` → ``Param(("g", "d", "t"))``.
1057
+
1058
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1059
+ shapes: scalar / 1d_map[period] / 1d_map[time] / 2d_map[period,time].
1060
+ """
1061
+ resolved = resolve_param_shape(
1062
+ source, "flowGroup", "min_instant_flow", period_filter=period_filter)
1063
+ return broadcast_to_period_time(
1064
+ resolved, "g", period_filter, filter_zero=True)
1065
+
1066
+
1067
+ # ---------------------------------------------------------------------------
1068
+ # Multi-class union helpers — params declared on each of unit/node/connection
1069
+ # (or unit/connection) and merged into one frame keyed on the generic
1070
+ # ``e`` (entity) or ``p`` (process) dim.
1071
+
1072
+ def _e_period_param_union(source: "InputSource",
1073
+ parameter_name: str,
1074
+ *,
1075
+ classes: tuple[str, ...] = ("unit", "node",
1076
+ "connection"),
1077
+ filter_zero: bool = False,
1078
+ period_filter: pl.DataFrame | None = None
1079
+ ) -> Param | None:
1080
+ """Union ``parameter_name`` across the supplied entity classes into a
1081
+ single ``Param(("e", "d"), [...])`` frame.
1082
+
1083
+ Δ.17c-Tier2 — each per-class call routes through
1084
+ :func:`._param_shapes.resolve_param_shape` +
1085
+ :func:`._param_shapes.broadcast_to_period`. This:
1086
+
1087
+ * carries silent-default ``index_name`` disambiguation
1088
+ (spinedb_api's ``"x"`` resolves structurally via the
1089
+ ``{SCALAR, MAP_PERIOD}`` allow-list at depth 1; value-domain
1090
+ probing kicks in if the labels are still ambiguous), and
1091
+ * promotes scalar-authored parameters to ``(e, d)`` via the
1092
+ period filter (the pre-migration path silently dropped these
1093
+ because ``period_col`` was ``None``).
1094
+
1095
+ Each per-class ``broadcast_to_period`` call aliases the source's
1096
+ ``name`` column to the union ``e`` axis (``cast_frame_axes``
1097
+ resolves ``"e"`` to the entity-union Enum), so the per-class
1098
+ Param frames concat cleanly along the union vocabulary.
1099
+
1100
+ ``period_filter`` is REQUIRED for scalar-authored sources (the
1101
+ SCALAR branch in :func:`broadcast_to_period` cross-joins with the
1102
+ period axis to emit ``(e, d)``). Callers that don't have a period
1103
+ filter on hand should default to passing the active solve's ``dt``
1104
+ frame, mirroring Phase 1's pdGroup pattern.
1105
+ """
1106
+ parts: list[pl.LazyFrame] = []
1107
+ for cls in classes:
1108
+ resolved = resolve_param_shape(
1109
+ source, cls, parameter_name, period_filter=period_filter)
1110
+ part = broadcast_to_period(
1111
+ resolved, "e", period_filter, filter_zero=filter_zero)
1112
+ if part is None:
1113
+ continue
1114
+ # ``broadcast_to_period`` returns a Param whose dims are either
1115
+ # ``(e, d)`` (MAP_PERIOD or SCALAR-broadcast) or ``(e,)`` (SCALAR
1116
+ # with no period_filter — guarded against above). For the
1117
+ # Tier-2 union we require the (e, d) shape; the SCALAR-without-
1118
+ # period-filter case is caller error (helpers below always pass
1119
+ # period_filter=dt).
1120
+ if part.dims != ("e", "d"):
1121
+ raise FlexToolConfigError(
1122
+ f"_e_period_param_union expected ('e', 'd') dims from "
1123
+ f"class {cls!r} on parameter {parameter_name!r}; got "
1124
+ f"{part.dims}. Pass a non-empty period_filter when "
1125
+ "calling the ed_* helpers."
1126
+ )
1127
+ lf = part.frame
1128
+ if not isinstance(lf, pl.LazyFrame):
1129
+ lf = lf.lazy()
1130
+ parts.append(lf.select("e", "d", "value"))
1131
+ if not parts:
1132
+ return None
1133
+ out = pl.concat(parts).collect()
1134
+ if out.height == 0:
1135
+ return None
1136
+ return Param(("e", "d"), out.lazy().sort("e", "d"))
1137
+
1138
+
1139
+ def ed_invest_max_period_from_source(source: "InputSource",
1140
+ period_filter: pl.DataFrame | None = None,
1141
+ ) -> Param | None:
1142
+ """``unit/node/connection.invest_max_period`` 1d_map(period) →
1143
+ ``Param(("e", "d"))``. CSV path keeps zero rows (per
1144
+ ``_read_period_cap``); we preserve that.
1145
+
1146
+ Δ.17c-Tier2 — uses ``resolve_param_shape`` + ``broadcast_to_period``
1147
+ per class via :func:`_e_period_param_union`.
1148
+ """
1149
+ return _e_period_param_union(source, "invest_max_period",
1150
+ filter_zero=False,
1151
+ period_filter=period_filter)
1152
+
1153
+
1154
+ def ed_divest_max_period_from_source(source: "InputSource",
1155
+ period_filter: pl.DataFrame | None = None,
1156
+ ) -> Param | None:
1157
+ """Δ.17c-Tier2 — see :func:`ed_invest_max_period_from_source`."""
1158
+ return _e_period_param_union(source, "retire_max_period",
1159
+ filter_zero=False,
1160
+ period_filter=period_filter)
1161
+
1162
+
1163
+ def ed_invest_min_period_from_source(source: "InputSource",
1164
+ period_filter: pl.DataFrame | None = None,
1165
+ ) -> Param | None:
1166
+ """CSV path drops zero rows (``_read_e_d_param``).
1167
+ Δ.17c-Tier2 — see :func:`ed_invest_max_period_from_source`.
1168
+ """
1169
+ return _e_period_param_union(source, "invest_min_period",
1170
+ filter_zero=True,
1171
+ period_filter=period_filter)
1172
+
1173
+
1174
+ def ed_divest_min_period_from_source(source: "InputSource",
1175
+ period_filter: pl.DataFrame | None = None,
1176
+ ) -> Param | None:
1177
+ """Δ.17c-Tier2 — see :func:`ed_invest_max_period_from_source`."""
1178
+ return _e_period_param_union(source, "retire_min_period",
1179
+ filter_zero=True,
1180
+ period_filter=period_filter)
1181
+
1182
+
1183
+ def ed_cumulative_max_capacity_from_source(source: "InputSource",
1184
+ period_filter: pl.DataFrame | None = None,
1185
+ ) -> Param | None:
1186
+ """Δ.17c-Tier2 — see :func:`ed_invest_max_period_from_source`."""
1187
+ return _e_period_param_union(source, "cumulative_max_capacity",
1188
+ filter_zero=True,
1189
+ period_filter=period_filter)
1190
+
1191
+
1192
+ def ed_cumulative_min_capacity_from_source(source: "InputSource",
1193
+ period_filter: pl.DataFrame | None = None,
1194
+ ) -> Param | None:
1195
+ """Δ.17c-Tier2 — see :func:`ed_invest_max_period_from_source`."""
1196
+ return _e_period_param_union(source, "cumulative_min_capacity",
1197
+ filter_zero=True,
1198
+ period_filter=period_filter)
1199
+
1200
+
1201
+ # ---------------------------------------------------------------------------
1202
+ # §1.9, §1.14 — relationship scalars (sink / source side params).
1203
+ #
1204
+ # CSV path (``_read_p_process_side``) reads ``input/p_process_sink.csv`` /
1205
+ # ``input/p_process_source.csv`` and filters ``value != 0``. We mirror.
1206
+
1207
+ def _p_side_scalar(source: "InputSource", entity_class: str,
1208
+ parameter_name: str, side_dim: str,
1209
+ entity_col: str = "unit",
1210
+ *,
1211
+ filter_zero: bool = True) -> Param | None:
1212
+ """``unit__inputNode`` / ``unit__outputNode`` per-(p, side) scalar.
1213
+ ``entity_col`` is the Spine column name for the unit ('unit' or
1214
+ 'connection'); ``side_dim`` is 'source' or 'sink'.
1215
+ """
1216
+ try:
1217
+ df = source.parameter_explicit(entity_class, parameter_name)
1218
+ except (KeyError, AttributeError):
1219
+ try:
1220
+ df = source.parameter(entity_class, parameter_name)
1221
+ except KeyError:
1222
+ return None
1223
+ if df is None or df.height == 0:
1224
+ return None
1225
+ if "value" not in df.columns or entity_col not in df.columns \
1226
+ or "node" not in df.columns:
1227
+ return None
1228
+ lf = (df.lazy()
1229
+ .rename({entity_col: "p", "node": side_dim})
1230
+ .filter(pl.col("value").is_not_null()))
1231
+ if filter_zero:
1232
+ lf = lf.filter(pl.col("value") != 0.0)
1233
+ out = lf.select("p", side_dim, "value").collect()
1234
+ if out.height == 0:
1235
+ return None
1236
+ return Param(("p", side_dim), out.lazy())
1237
+
1238
+
1239
+ # ramp_speed (§1.9) — relationship scalar, CSV filters zero
1240
+ def p_ramp_speed_up_sink_from_source(source: "InputSource") -> Param | None:
1241
+ return _p_side_scalar(source, "unit__outputNode", "ramp_speed_up", "sink")
1242
+
1243
+
1244
+ def p_ramp_speed_down_sink_from_source(source: "InputSource") -> Param | None:
1245
+ return _p_side_scalar(source, "unit__outputNode", "ramp_speed_down", "sink")
1246
+
1247
+
1248
+ def p_ramp_speed_up_source_from_source(source: "InputSource") -> Param | None:
1249
+ return _p_side_scalar(source, "unit__inputNode", "ramp_speed_up", "source")
1250
+
1251
+
1252
+ def p_ramp_speed_down_source_from_source(source: "InputSource") -> Param | None:
1253
+ return _p_side_scalar(source, "unit__inputNode", "ramp_speed_down", "source")
1254
+
1255
+
1256
+ def p_process_sink_min_capacity_coef_from_source(source: "InputSource") -> Param | None:
1257
+ """``unit__outputNode.capacity_min_coeff`` → ``Param(("p", "sink"))``.
1258
+
1259
+ Default 1.0 (schema). Per-output-arc multiplier on the
1260
+ ``minFlow_minload`` floor (``v_online · min_load``), mirroring the
1261
+ .mod's ``p_process_sink_min_capacity_coefficient`` (flextool.mod
1262
+ L3075). Explicit rows only, INCLUDING an authored ``0.0`` ("no floor
1263
+ on this arc") — ``filter_zero=False`` keeps it; the consumer densifies
1264
+ absent arcs to the 1.0 default.
1265
+ """
1266
+ return _p_side_scalar(source, "unit__outputNode", "capacity_min_coeff",
1267
+ "sink", filter_zero=False)
1268
+
1269
+
1270
+ # inertia_constant (§1.14) — relationship scalar, CSV filters zero
1271
+ def p_process_sink_inertia_constant_from_source(source: "InputSource") -> Param | None:
1272
+ return _p_side_scalar(source, "unit__outputNode", "inertia_constant", "sink")
1273
+
1274
+
1275
+ def p_process_source_inertia_constant_from_source(source: "InputSource") -> Param | None:
1276
+ return _p_side_scalar(source, "unit__inputNode", "inertia_constant", "source")
1277
+
1278
+
1279
+ # ---------------------------------------------------------------------------
1280
+ # §1.10 — UC: startup_cost (1d_map period)
1281
+
1282
+ def p_startup_cost_from_source(source: "InputSource",
1283
+ period_filter: pl.DataFrame | None = None,
1284
+ ) -> Param | None:
1285
+ """``unit.startup_cost`` → ``Param(("p", "d"))``.
1286
+
1287
+ Δ.17c-Tier1 — uses :func:`._param_shapes.resolve_param_shape`.
1288
+ Allowed shapes: scalar / 1d_map[period]. CSV path filters zero rows
1289
+ (``_load_online``: ``filter(value != 0)``); we mirror via
1290
+ ``filter_zero=True``.
1291
+ """
1292
+ resolved = resolve_param_shape(
1293
+ source, "unit", "startup_cost", period_filter=period_filter)
1294
+ return broadcast_to_period(
1295
+ resolved, "p", period_filter, filter_zero=True)
1296
+
1297
+
1298
+ # ---------------------------------------------------------------------------
1299
+ # §1.11 — Storage: Map(period→time) parameters
1300
+
1301
+ def p_node_availability_from_source(source: "InputSource",
1302
+ period_filter: pl.DataFrame | None = None,
1303
+ ) -> Param | None:
1304
+ """``node.availability`` → ``Param(("n", "d", "t"))``.
1305
+
1306
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1307
+ shapes: scalar / 1d_map[period] / 1d_map[time] / 2d_map[period,time].
1308
+ CSV path slices ``pdtNode.csv`` and filters to nodeState entities at
1309
+ the apply site; the helper here returns ALL availability rows. The
1310
+ nodeState filter is applied downstream by ``_load_storage``.
1311
+ """
1312
+ resolved = resolve_param_shape(
1313
+ source, "node", "availability", period_filter=period_filter)
1314
+ return broadcast_to_period_time(resolved, "n", period_filter)
1315
+
1316
+
1317
+ def p_storage_state_reference_value_from_source(source: "InputSource",
1318
+ period_filter: pl.DataFrame | None = None,
1319
+ ) -> Param | None:
1320
+ """``node.storage_state_reference_value`` → ``Param(("n", "d", "t"))``.
1321
+
1322
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1323
+ shapes: scalar / 1d_map[period] / 1d_map[time] / 2d_map[period,time].
1324
+ CSV path (``_load_storage``) gates on ``use_reference_value`` set;
1325
+ the gate stays on the consumer side.
1326
+ """
1327
+ resolved = resolve_param_shape(
1328
+ source, "node", "storage_state_reference_value",
1329
+ period_filter=period_filter)
1330
+ return broadcast_to_period_time(resolved, "n", period_filter)
1331
+
1332
+
1333
+ # ---------------------------------------------------------------------------
1334
+ # §1.4 — CO2: group price + period cap
1335
+
1336
+ def p_co2_price_from_source(source: "InputSource",
1337
+ period_filter: pl.DataFrame | None = None,
1338
+ ) -> Param | None:
1339
+ """``group.co2_price`` → ``Param(("g", "d", "t"))``.
1340
+
1341
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape` to discover
1342
+ the actual shape from the DB and validate against the per-parameter
1343
+ allow-list (scalar / 1d_map[period] / 1d_map[time] /
1344
+ 2d_map[period,time]). CSV path slices ``pdtGroup.csv``
1345
+ (param='co2_price'). None default on the schema — explicit rows
1346
+ only.
1347
+ """
1348
+ resolved = resolve_param_shape(
1349
+ source, "group", "co2_price", period_filter=period_filter)
1350
+ return broadcast_to_period_time(resolved, "g", period_filter)
1351
+
1352
+
1353
+ def p_co2_max_period_from_source(source: "InputSource",
1354
+ period_filter: pl.DataFrame | None = None,
1355
+ ) -> Param | None:
1356
+ """``group.co2_max_period`` → ``Param(("g", "d"))``.
1357
+
1358
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1359
+ shapes: scalar / 1d_map[period]. CSV path keys this off
1360
+ ``inp/pd_group.csv`` slice.
1361
+
1362
+ Gate-coupled note: at the consumer site this Param is only emitted
1363
+ into the LP when the (g, c, n) join with ``commodity_node_co2`` ×
1364
+ ``group__node`` is non-empty (see ``_load_co2_cap``). We return the
1365
+ Param unconditionally — the consumer decides whether to wire it.
1366
+ """
1367
+ resolved = resolve_param_shape(
1368
+ source, "group", "co2_max_period", period_filter=period_filter)
1369
+ return broadcast_to_period(resolved, "g", period_filter)
1370
+
1371
+
1372
+ # ---------------------------------------------------------------------------
1373
+ # §1.12 — Variable cost (other_operational_cost) Map(period→time)
1374
+
1375
+ def p_pdt_varCost_source_from_source(
1376
+ source: "InputSource",
1377
+ period_filter: pl.DataFrame | None = None,
1378
+ ) -> Param | None:
1379
+ """``unit__inputNode.other_operational_cost`` Map(period→time) →
1380
+ ``Param(("p", "source", "d", "t"))``. CSV path filters zero.
1381
+
1382
+ Δ.17c-Tier3 — routes through ``resolve_param_shape`` +
1383
+ ``broadcast_to_period_time`` to carry silent-default ``index_name``
1384
+ disambiguation; the per-source-column rename dict maps the
1385
+ relationship class's ``(unit, node)`` columns to ``(p, source)``.
1386
+ """
1387
+ resolved = resolve_param_shape(
1388
+ source, "unit__inputNode", "other_operational_cost",
1389
+ period_filter=period_filter)
1390
+ return broadcast_to_period_time(
1391
+ resolved, {"unit": "p", "node": "source"},
1392
+ period_filter, filter_zero=True)
1393
+
1394
+
1395
+ def p_pdt_varCost_sink_from_source(
1396
+ source: "InputSource",
1397
+ period_filter: pl.DataFrame | None = None,
1398
+ ) -> Param | None:
1399
+ """``unit__outputNode.other_operational_cost`` Map(period→time) →
1400
+ ``Param(("p", "sink", "d", "t"))``.
1401
+
1402
+ Δ.17c-Tier3 — see :func:`p_pdt_varCost_source_from_source`.
1403
+ """
1404
+ resolved = resolve_param_shape(
1405
+ source, "unit__outputNode", "other_operational_cost",
1406
+ period_filter=period_filter)
1407
+ return broadcast_to_period_time(
1408
+ resolved, {"unit": "p", "node": "sink"},
1409
+ period_filter, filter_zero=True)
1410
+
1411
+
1412
+ def p_pdt_varCost_process_from_source(
1413
+ source: "InputSource",
1414
+ period_filter: pl.DataFrame | None = None,
1415
+ ) -> Param | None:
1416
+ """``connection.other_operational_cost`` Map(period→time) →
1417
+ ``Param(("p", "d", "t"))``. Single-class helper.
1418
+
1419
+ Δ.17c-Tier3 — the call routes through ``resolve_param_shape`` +
1420
+ ``broadcast_to_period_time``. Pre-migration the helper gated on
1421
+ ``{"name", "period", "t"}.issubset``, silently dropping silent-
1422
+ default-Map authoring (column ``"x"`` not ``"period"``) and
1423
+ SCALAR / MAP_PERIOD / MAP_TIME shapes.
1424
+
1425
+ SCALAR / MAP_PERIOD / MAP_TIME branches return Params with fewer
1426
+ dims than the canonical ``(p, d, t)``. Results are broadcast up
1427
+ to ``(p, d, t)`` against ``period_filter``'s ``(d, t)`` axis so
1428
+ the output frame is dim-uniform. Callers (the dispatcher) always
1429
+ pass a non-empty ``period_filter``; if the Param is a strict
1430
+ subset of ``(p, d, t)`` AND no period_filter is on hand, we raise
1431
+ rather than emit an incompatible-shape frame.
1432
+ """
1433
+ parts: list[pl.LazyFrame] = []
1434
+ for cls in ("connection",):
1435
+ resolved = resolve_param_shape(
1436
+ source, cls, "other_operational_cost",
1437
+ period_filter=period_filter)
1438
+ part = broadcast_to_period_time(
1439
+ resolved, {"name": "p"}, period_filter, filter_zero=True)
1440
+ if part is None:
1441
+ continue
1442
+ lf = part.frame
1443
+ if not isinstance(lf, pl.LazyFrame):
1444
+ lf = lf.lazy()
1445
+ if part.dims == ("p", "d", "t"):
1446
+ parts.append(lf.select("p", "d", "t", "value"))
1447
+ continue
1448
+ # Lower-dim Param shapes (SCALAR / MAP_PERIOD / MAP_TIME) — the
1449
+ # union concat requires (p, d, t); broadcast the missing axes
1450
+ # against ``period_filter`` so the multi-class union stays
1451
+ # dim-uniform. Mirrors the legacy concat semantic (the inline
1452
+ # helper accepted only MAP_PERIOD_TIME-shaped sources because
1453
+ # of its column gate; Tier-3 widens that to the full allow-list
1454
+ # by broadcasting the lower-dim shapes here).
1455
+ if period_filter is None or period_filter.height == 0:
1456
+ raise FlexToolConfigError(
1457
+ f"p_pdt_varCost_process_from_source: class {cls!r} "
1458
+ f"resolved to Param dims {part.dims}; cannot broadcast "
1459
+ "to ('p', 'd', 't') without a period_filter. Pass "
1460
+ "period_filter=dt from the dispatcher.")
1461
+ if part.dims == ("p",):
1462
+ # SCALAR — cross-join with full (d, t).
1463
+ dt_lf = period_filter.lazy().select("d", "t").unique()
1464
+ lf = lf.join(dt_lf, how="cross").select("p", "d", "t", "value")
1465
+ elif part.dims == ("p", "d"):
1466
+ # MAP_PERIOD — cross-join across t within each d.
1467
+ t_lf = period_filter.lazy().select("d", "t").unique()
1468
+ lf = (lf.join(t_lf, on="d", how="inner")
1469
+ .select("p", "d", "t", "value"))
1470
+ elif part.dims == ("p", "t"):
1471
+ # MAP_TIME — cross-join across d within each t.
1472
+ d_lf = period_filter.lazy().select("d", "t").unique()
1473
+ lf = (lf.join(d_lf, on="t", how="inner")
1474
+ .select("p", "d", "t", "value"))
1475
+ else: # pragma: no cover — guarded by allow-list above.
1476
+ raise FlexToolConfigError(
1477
+ f"p_pdt_varCost_process_from_source: unexpected per-class "
1478
+ f"Param dims {part.dims}")
1479
+ parts.append(lf)
1480
+ if not parts:
1481
+ return None
1482
+ out = pl.concat(parts).collect()
1483
+ if out.height == 0:
1484
+ return None
1485
+ return Param(("p", "d", "t"), out.lazy().sort("p", "d", "t"))
1486
+
1487
+
1488
+ # ---------------------------------------------------------------------------
1489
+ # §1.15 — Reserves
1490
+
1491
+ def pdtReserve_upDown_group_reservation_from_source(
1492
+ source: "InputSource",
1493
+ period_filter: pl.DataFrame | None = None,
1494
+ ) -> Param | None:
1495
+ """``reserve__upDown__group.reservation`` Map(period→time) →
1496
+ ``Param(("r", "ud", "g", "d", "t"))``. Default 0.0 (schema) — CSV
1497
+ path emits explicit rows only (zero values are NOT filtered).
1498
+
1499
+ Δ.17c-Tier3 — routes through ``resolve_param_shape`` +
1500
+ ``broadcast_to_period_time`` with a per-source-column rename dict
1501
+ mapping the 3-dim relationship class's ``(reserve, upDown, group)``
1502
+ columns to ``(r, ud, g)``. ``filter_zero=False`` because the
1503
+ schema default of 0.0 makes explicit zero rows authoring-
1504
+ meaningful (legacy CSV path preserved zero rows for this param).
1505
+ """
1506
+ resolved = resolve_param_shape(
1507
+ source, "reserve__upDown__group", "reservation",
1508
+ period_filter=period_filter)
1509
+ return broadcast_to_period_time(
1510
+ resolved,
1511
+ {"reserve": "r", "upDown": "ud", "group": "g"},
1512
+ period_filter, filter_zero=False)
1513
+
1514
+
1515
+ def p_reserve_upDown_group_penalty_reserve_from_source(source: "InputSource") -> Param | None:
1516
+ """``reserve__upDown__group.penalty_reserve`` scalar →
1517
+ ``Param(("r", "ud", "g"))``. None default — explicit rows only.
1518
+ """
1519
+ try:
1520
+ df = source.parameter_explicit("reserve__upDown__group", "penalty_reserve")
1521
+ except (KeyError, AttributeError):
1522
+ try:
1523
+ df = source.parameter("reserve__upDown__group", "penalty_reserve")
1524
+ except KeyError:
1525
+ return None
1526
+ if df is None or df.height == 0:
1527
+ return None
1528
+ cols = df.columns
1529
+ if not {"reserve", "upDown", "group", "value"}.issubset(cols):
1530
+ return None
1531
+ lf = (df.lazy()
1532
+ .rename({"reserve": "r", "upDown": "ud", "group": "g"})
1533
+ .filter(pl.col("value").is_not_null()))
1534
+ out = lf.select("r", "ud", "g", "value").collect()
1535
+ if out.height == 0:
1536
+ return None
1537
+ return Param(("r", "ud", "g"), out.lazy())
1538
+
1539
+
1540
+ def _process_reserve_node_param(source: "InputSource",
1541
+ parameter_name: str) -> Param | None:
1542
+ """Union ``reserve__upDown__unit__node`` ∪
1543
+ ``reserve__upDown__connection__node`` for a per-(p, r, ud, n) scalar.
1544
+
1545
+ CSV path (``_read_p_process_reserve_node`` style) emits explicit rows
1546
+ only; we preserve via ``parameter_explicit``.
1547
+ """
1548
+ parts: list[pl.LazyFrame] = []
1549
+ for cls, ent in (("reserve__upDown__unit__node", "unit"),
1550
+ ("reserve__upDown__connection__node", "connection")):
1551
+ try:
1552
+ df = source.parameter_explicit(cls, parameter_name)
1553
+ except (KeyError, AttributeError):
1554
+ try:
1555
+ df = source.parameter(cls, parameter_name)
1556
+ except KeyError:
1557
+ continue
1558
+ if df is None or df.height == 0:
1559
+ continue
1560
+ cols = df.columns
1561
+ if not {"reserve", "upDown", ent, "node", "value"}.issubset(cols):
1562
+ continue
1563
+ parts.append(df.lazy()
1564
+ .rename({ent: "p", "reserve": "r",
1565
+ "upDown": "ud", "node": "n"})
1566
+ .filter(pl.col("value").is_not_null())
1567
+ .select("p", "r", "ud", "n", "value"))
1568
+ if not parts:
1569
+ return None
1570
+ out = pl.concat(parts).collect()
1571
+ if out.height == 0:
1572
+ return None
1573
+ return Param(("p", "r", "ud", "n"), out.lazy().sort("p", "r", "ud", "n"))
1574
+
1575
+
1576
+ def p_process_reserve_upDown_node_reliability_from_source(source: "InputSource") -> Param | None:
1577
+ """``reserve__upDown__{unit,connection}__node.reliability`` →
1578
+ ``Param(("p", "r", "ud", "n"))``. Default 1.0 (schema) — broadcast.
1579
+ The CSV path uses the broadcast default; here we delegate to
1580
+ ``parameter`` (not ``parameter_explicit``) so the default-fill kicks
1581
+ in. Result is the same shape as the CSV path's
1582
+ ``Param(("p", "r", "ud", "n"))``.
1583
+ """
1584
+ parts: list[pl.LazyFrame] = []
1585
+ for cls, ent in (("reserve__upDown__unit__node", "unit"),
1586
+ ("reserve__upDown__connection__node", "connection")):
1587
+ try:
1588
+ df = source.parameter(cls, parameter_name="reliability")
1589
+ except KeyError:
1590
+ continue
1591
+ if df is None or df.height == 0:
1592
+ continue
1593
+ cols = df.columns
1594
+ if not {"reserve", "upDown", ent, "node", "value"}.issubset(cols):
1595
+ continue
1596
+ parts.append(df.lazy()
1597
+ .rename({ent: "p", "reserve": "r",
1598
+ "upDown": "ud", "node": "n"})
1599
+ .filter(pl.col("value").is_not_null())
1600
+ .select("p", "r", "ud", "n", "value"))
1601
+ if not parts:
1602
+ return None
1603
+ out = pl.concat(parts).collect()
1604
+ if out.height == 0:
1605
+ return None
1606
+ return Param(("p", "r", "ud", "n"), out.lazy().sort("p", "r", "ud", "n"))
1607
+
1608
+
1609
+ def p_process_reserve_upDown_node_max_share_from_source(source: "InputSource") -> Param | None:
1610
+ return _process_reserve_node_param(source, "max_share")
1611
+
1612
+
1613
+ def p_process_reserve_upDown_node_large_failure_ratio_value_from_source(
1614
+ source: "InputSource") -> Param | None:
1615
+ return _process_reserve_node_param(source, "large_failure_ratio")
1616
+
1617
+
1618
+ def p_process_reserve_upDown_node_increase_reserve_ratio_value_from_source(
1619
+ source: "InputSource") -> Param | None:
1620
+ return _process_reserve_node_param(source, "increase_reserve_ratio")
1621
+
1622
+
1623
+ # ---------------------------------------------------------------------------
1624
+ # §1.17 — Delayed processes (process_delayed__duration)
1625
+
1626
+ def process_delayed__duration_from_source(source: "InputSource") -> pl.DataFrame | None:
1627
+ """``unit/connection.delay`` 1d_map(td) → ``[p, td]`` set frame.
1628
+
1629
+ CSV path (``_delay.load_data``) reads ``solve_data/process_delayed__duration.csv``
1630
+ and returns the (p, td) keys as a DataFrame (not a Param — it's a
1631
+ set, since the duration values are 1.0 or absent).
1632
+
1633
+ Note: this is a *set* in FlexData, not a Param — return
1634
+ type matches the field declared on FlexData.
1635
+ """
1636
+ parts: list[pl.LazyFrame] = []
1637
+ for cls in ("unit", "connection"):
1638
+ try:
1639
+ df = source.parameter_explicit(cls, "delay")
1640
+ except (KeyError, AttributeError):
1641
+ try:
1642
+ df = source.parameter(cls, "delay")
1643
+ except KeyError:
1644
+ continue
1645
+ if df is None or df.height == 0:
1646
+ continue
1647
+ cols = df.columns
1648
+ if "name" not in cols:
1649
+ continue
1650
+ # 1d_map(td) — the index column may be 'td' or default 'period'/'i'.
1651
+ # Detect by elimination: any column not in {name, value} is the index.
1652
+ idx_cols = [c for c in cols if c not in ("name", "value")]
1653
+ if len(idx_cols) != 1:
1654
+ continue
1655
+ idx = idx_cols[0]
1656
+ lf = (df.lazy()
1657
+ .rename({"name": "p", idx: "td"})
1658
+ .filter(pl.col("value").is_not_null())
1659
+ .filter(pl.col("value") != 0.0))
1660
+ parts.append(lf.select("p", "td"))
1661
+ if not parts:
1662
+ return None
1663
+ out = pl.concat(parts).unique().collect()
1664
+ if out.height == 0:
1665
+ return None
1666
+ return out.sort("p", "td")
1667
+
1668
+
1669
+ # ---------------------------------------------------------------------------
1670
+ # Penalty / availability scalars (§1.2 / §1.7) — schema-default broadcast.
1671
+ #
1672
+ # These differ from the "explicit only" pattern: schema sentinel default
1673
+ # (e.g. 10000.0 for penalty_up) means the CSV path receives one row per
1674
+ # entity with the broadcast default. ``SpineDbReader.parameter()``
1675
+ # already broadcasts when ``default is not None``; we just rename.
1676
+
1677
+ def _entity_scalar_with_default(source: "InputSource", entity_class: str,
1678
+ parameter_name: str,
1679
+ dim: str) -> Param | None:
1680
+ """Variant of :func:`_entity_scalar_explicit` that uses
1681
+ :meth:`parameter` (default-broadcast) instead of ``parameter_explicit``.
1682
+ """
1683
+ try:
1684
+ df = source.parameter(entity_class, parameter_name)
1685
+ except KeyError:
1686
+ return None
1687
+ if df is None or df.height == 0:
1688
+ return None
1689
+ return Param((dim,),
1690
+ df.lazy().rename({"name": dim}).select(dim, "value"))
1691
+
1692
+
1693
+ # ---------------------------------------------------------------------------
1694
+ # §1.7, §1.3 — additional Map(period→time) scalars on object classes.
1695
+
1696
+ def p_process_availability_from_source(source: "InputSource",
1697
+ period_filter: pl.DataFrame | None = None,
1698
+ ) -> Param | None:
1699
+ """``unit/connection.availability`` → Param keyed on ``"p"`` plus
1700
+ whichever of ``"d"`` / ``"t"`` the union of unit + connection
1701
+ authoring requires.
1702
+
1703
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape` for each of
1704
+ ``unit.availability`` and ``connection.availability`` independently
1705
+ (Spine carries them on separate classes; the LP consumes the union).
1706
+ Allowed shapes per class: scalar / 1d_map[period] / 1d_map[time] /
1707
+ 2d_map[period,time]. CSV path slices ``pdtProcess.csv``
1708
+ (param='availability') and emits explicit rows only.
1709
+
1710
+ Phase E.1 — each per-class Param keeps its authored dims under the
1711
+ lazified broadcast helpers. When the two classes carry different
1712
+ shapes (e.g. one SCALAR, one MAP_TIME) we promote each part to the
1713
+ union of their dims via lazy joins on ``period_filter`` before the
1714
+ concat, so the output Param has a single consistent dim shape but
1715
+ the upstream LazyFrames remain unmaterialised.
1716
+ """
1717
+ parts: list[Param] = []
1718
+ for cls in ("unit", "connection"):
1719
+ resolved = resolve_param_shape(
1720
+ source, cls, "availability", period_filter=period_filter)
1721
+ param = broadcast_to_period_time(resolved, "p", period_filter)
1722
+ if param is None:
1723
+ continue
1724
+ parts.append(param)
1725
+ if not parts:
1726
+ return None
1727
+ # Union of all per-part dims preserving order: always starts with
1728
+ # "p"; "d" / "t" appear only when at least one part authored them.
1729
+ union_dims: tuple[str, ...] = ("p",)
1730
+ if any("d" in pt.dims for pt in parts):
1731
+ union_dims = union_dims + ("d",)
1732
+ if any("t" in pt.dims for pt in parts):
1733
+ union_dims = union_dims + ("t",)
1734
+ # Promote each part to the union dims via lazy joins on
1735
+ # period_filter. No-op when the part already carries union_dims.
1736
+ pf_lf = (period_filter.lazy() if period_filter is not None
1737
+ and period_filter.height > 0 else None)
1738
+ part_lfs: list[pl.LazyFrame] = []
1739
+ for pt in parts:
1740
+ lf = pt.lazy
1741
+ missing = [d for d in union_dims if d not in pt.dims]
1742
+ if missing:
1743
+ if pf_lf is None:
1744
+ # Cannot promote without a period_filter to source the
1745
+ # missing axis values — defensive guard, the helper's
1746
+ # contract requires a non-empty filter for any broadcast.
1747
+ return None
1748
+ # Build the (missing-dim) universe from period_filter.
1749
+ uni = pf_lf.select(missing).unique()
1750
+ lf = lf.join(uni, how="cross")
1751
+ lf = lf.select(*union_dims, "value")
1752
+ part_lfs.append(lf)
1753
+ out_lf = pl.concat(part_lfs).sort(*union_dims)
1754
+ return Param(union_dims, out_lf)
1755
+
1756
+
1757
+ def p_commodity_price_from_source(source: "InputSource",
1758
+ period_filter: pl.DataFrame | None = None,
1759
+ ) -> Param | None:
1760
+ """``commodity.price`` → ``Param(("c", "d", "t"))``.
1761
+
1762
+ Δ.17c — uses :func:`._param_shapes.resolve_param_shape`. Allowed
1763
+ shapes: scalar / 1d_map[period] / 1d_map[time] (no 2d_map per
1764
+ flextool's ``write_pdtCommodity`` cascade pt → pd → p → 0). None
1765
+ default on the schema; CSV path slices ``pdtCommodity.csv``
1766
+ (param='price') and emits explicit rows only.
1767
+ """
1768
+ resolved = resolve_param_shape(
1769
+ source, "commodity", "price", period_filter=period_filter)
1770
+ return broadcast_to_period_time(resolved, "c", period_filter)
1771
+
1772
+
1773
+ # ---------------------------------------------------------------------------
1774
+ # §1.19 — Commodity ladder split (price / quantity sub-frames).
1775
+ #
1776
+ # Spine schema: ``commodity.price_ladder_annual`` is a 3-level Map
1777
+ # ``(period → tier → {"price","quantity"} → f64)`` which the
1778
+ # SpineDbReader unrolls to ``[name, period, tier, x, value]`` where
1779
+ # ``x ∈ {"price", "quantity"}``. We split it into two Params keyed
1780
+ # (c, i, d) with ``value`` being the price (resp. quantity) only.
1781
+ # ``commodity.price_ladder_cumulative`` mirrors but without period.
1782
+
1783
+ def _ladder_split(source: "InputSource", parameter_name: str,
1784
+ *, with_period: bool) -> tuple[Param | None, Param | None]:
1785
+ """Return ``(price_param, quantity_param)`` from the named ladder
1786
+ parameter. ``with_period=True`` means the unroll produces a
1787
+ ``period`` column (annual ladder); ``False`` means tier-only
1788
+ (cumulative ladder).
1789
+ """
1790
+ try:
1791
+ df = source.parameter_explicit("commodity", parameter_name)
1792
+ except (KeyError, AttributeError):
1793
+ try:
1794
+ df = source.parameter("commodity", parameter_name)
1795
+ except KeyError:
1796
+ return None, None
1797
+ if df is None or df.height == 0:
1798
+ return None, None
1799
+ cols = df.columns
1800
+ needed = {"name", "tier", "value"}
1801
+ if with_period:
1802
+ needed.add("period")
1803
+ if not needed.issubset(cols):
1804
+ return None, None
1805
+ # Detect the leaf-discriminator column ("x" or whatever name carries
1806
+ # the "price"/"quantity" tag). All non-(name, period, tier, value)
1807
+ # columns are candidates; pick the first.
1808
+ keep_cols = ({"name", "period", "tier", "value"} if with_period
1809
+ else {"name", "tier", "value"})
1810
+ leaf_cols = [c for c in cols if c not in keep_cols]
1811
+ if len(leaf_cols) != 1:
1812
+ return None, None
1813
+ leaf = leaf_cols[0]
1814
+ rename = {"name": "c", "tier": "i"}
1815
+ if with_period:
1816
+ rename["period"] = "d"
1817
+ base = (df.lazy()
1818
+ .rename(rename)
1819
+ .filter(pl.col("value").is_not_null())
1820
+ .with_columns(pl.col("i").cast(pl.Utf8)))
1821
+ out_dims = ("c", "i", "d") if with_period else ("c", "i")
1822
+ price_lf = base.filter(pl.col(leaf) == "price").select(*out_dims, "value")
1823
+ qty_lf = base.filter(pl.col(leaf) == "quantity").select(*out_dims, "value")
1824
+ price_df = price_lf.collect()
1825
+ qty_df = qty_lf.collect()
1826
+ p_price = Param(out_dims, price_df) if price_df.height > 0 else None
1827
+ p_qty = Param(out_dims, qty_df) if qty_df.height > 0 else None
1828
+ return p_price, p_qty
1829
+
1830
+
1831
+ def p_ladder_ann_price_from_source(source: "InputSource") -> Param | None:
1832
+ return _ladder_split(source, "price_ladder_annual", with_period=True)[0]
1833
+
1834
+
1835
+ def p_ladder_ann_quantity_from_source(source: "InputSource") -> Param | None:
1836
+ return _ladder_split(source, "price_ladder_annual", with_period=True)[1]
1837
+
1838
+
1839
+ def p_ladder_cum_price_from_source(source: "InputSource") -> Param | None:
1840
+ return _ladder_split(source, "price_ladder_cumulative", with_period=False)[0]
1841
+
1842
+
1843
+ def p_ladder_cum_quantity_from_source(source: "InputSource") -> Param | None:
1844
+ return _ladder_split(source, "price_ladder_cumulative", with_period=False)[1]
1845
+
1846
+
1847
+ def apply_direct_params_a(source: "InputSource",
1848
+ flex_data: object) -> None:
1849
+ """Pass 1a of Direct Params — the dt-independent assignments.
1850
+
1851
+ Δ.28 — split from the legacy single-pass ``apply_direct_params``
1852
+ so the dispatcher can run :func:`apply_derived_a` (which populates
1853
+ ``flex_data.dt``) BEFORE the broadcast-needing assignments in
1854
+ :func:`apply_direct_params_b` execute. See module docstring on the
1855
+ bug Δ.28 fixes (fast path's ``flex_data.dt`` is empty when
1856
+ ``apply_direct_params`` runs, so scalar / 1d_map[period] /
1857
+ 1d_map[time] values authored on the source can't broadcast across
1858
+ the active solve's ``(d, t)`` axis and the Param ends up empty —
1859
+ e.g. ``p_commodity_price`` on ``work_lh2_three_region`` was empty
1860
+ on the fast path even though Spine carries a scalar 30 for coal).
1861
+
1862
+ The slow path still calls these passes in sequence and the legacy
1863
+ behaviour is preserved exactly — slow-path ``flex_data.dt`` is
1864
+ already populated from CSVs by ``_load_*`` before
1865
+ ``_apply_db_overrides`` runs, so pass-1a / pass-1b ordering is a
1866
+ no-op there.
1867
+
1868
+ This pass writes:
1869
+ * §5.2.1 scalar Params (``p_co2_content`` etc.).
1870
+ * §5.2.3 relationship 1d_map (constraint coefficients).
1871
+ * §5.2.1 invest/divest total caps.
1872
+ * §1.16 second-wave scalars.
1873
+
1874
+ All of these consume ``source`` only — no ``flex_data.dt``
1875
+ dependency.
1876
+
1877
+ Δ.12b — assignment is unconditional for the scalar / 1d_map and
1878
+ relationship-1d_map helpers (these read the source unchanged and
1879
+ return None only when no upstream row exists, which is a
1880
+ legitimate "feature inactive" outcome).
1881
+ """
1882
+ # ─── §5.2.1 scalar Params with FlexData fields — Δ.12b unconditional
1883
+ flex_data.p_co2_content = p_co2_content_from_source(source)
1884
+ flex_data.p_constraint_constant = p_constraint_constant_from_source(source)
1885
+
1886
+ # ─── §5.2.3 relationship 1d_map (constraint coefficients) ───────────
1887
+ flex_data.p_node_constraint_invested_capacity_coeff = (
1888
+ _node_constraint_coef(source, "constraint_invested_capacity_coeff"))
1889
+ flex_data.p_process_constraint_invested_capacity_coeff = (
1890
+ _process_constraint_coef(source, "constraint_invested_capacity_coeff"))
1891
+ flex_data.p_node_constraint_state_coeff = (
1892
+ _node_constraint_coef(source, "constraint_state_coeff"))
1893
+ flex_data.p_node_constraint_prebuilt_capacity_coeff = (
1894
+ _node_constraint_coef(source,
1895
+ "constraint_cumulative_pre_built_capacity_coeff"))
1896
+ flex_data.p_process_constraint_prebuilt_capacity_coeff = (
1897
+ _process_constraint_coef(source,
1898
+ "constraint_cumulative_pre_built_capacity_coeff"))
1899
+
1900
+ # ─── §5.2.1 invest/divest total caps (entity-unioned) ───────────────
1901
+ # Max variants: keyed on entityInvest (resp. entityDivest), one row
1902
+ # per entity with value defaulting to 0 — mirrors
1903
+ # ``entity_total_caps.py:_compute_entity_total`` and
1904
+ # ``input.py::_e_total_param``.
1905
+ flex_data.e_invest_max_total = _e_total_param(
1906
+ source, "invest_max_total", kind="invest")
1907
+ flex_data.e_divest_max_total = _e_total_param(
1908
+ source, "retire_max_total", kind="divest")
1909
+ # Min variants: CSV path filters out zero rows (input.py::_read_e_param)
1910
+ # — None when no entity has an explicit non-zero min cap.
1911
+ flex_data.e_invest_min_total = _e_total_param(
1912
+ source, "invest_min_total", kind="invest", filter_zero=True)
1913
+ flex_data.e_divest_min_total = _e_total_param(
1914
+ source, "retire_min_total", kind="divest", filter_zero=True)
1915
+
1916
+ # ─── Δ.4 second wave — node scalars (storage feature) ───────────────
1917
+ flex_data.p_state_self_discharge = p_state_self_discharge_from_source(source)
1918
+ flex_data.p_state_start = p_state_start_from_source(source)
1919
+
1920
+ # ─── Δ.4 second wave — process scalars (online / UC feature) ────────
1921
+ flex_data.p_min_load = p_min_load_from_source(source)
1922
+ # Per-output-arc min_capacity_coefficient — scales the minFlow_minload
1923
+ # floor. Assigned in pass 1a so it is present on the synthetic / rolling
1924
+ # sub-solve path too (passes 3-10 are skipped there).
1925
+ flex_data.p_process_sink_min_capacity_coef = (
1926
+ p_process_sink_min_capacity_coef_from_source(source))
1927
+
1928
+ # ─── Δ.4 second wave — connection scalars (DC power flow feature) ───
1929
+ # Δ.16 — preserve the CSV-loaded value when the source has no rows.
1930
+ # Some fixtures (e.g. ``work_dc_power_flow`` / ``case14.sqlite``)
1931
+ # ship a pre-computed CSV but don't carry the parameter on the
1932
+ # ``connection`` class in the DB.
1933
+ _pcs_src = p_connection_susceptance_from_source(source)
1934
+ if _pcs_src is not None:
1935
+ flex_data.p_connection_susceptance = _pcs_src
1936
+
1937
+ # ─── Δ.4 second wave — commodity scalars (price ladder feature) ─────
1938
+ flex_data.p_commodity_unitsize = p_commodity_unitsize_from_source(source)
1939
+
1940
+
1941
+ def apply_direct_params_b(source: "InputSource",
1942
+ flex_data: object) -> None:
1943
+ """Pass 1b of Direct Params — the dt-dependent assignments.
1944
+
1945
+ Δ.28 — runs AFTER :func:`apply_derived_a` populates
1946
+ ``flex_data.dt`` for the active solve. Every helper here is a
1947
+ broadcast-needing Direct Param (scalar / 1d_map[period] /
1948
+ 1d_map[time] values authored on the source need ``dt`` to fan out
1949
+ across the per-(d, t) axis).
1950
+
1951
+ Helpers in this pass either:
1952
+ * consume ``period_filter=dt`` directly (scalar broadcast inside
1953
+ ``broadcast_to_period_time`` / ``broadcast_to_period`` /
1954
+ ``_entity_period_scalar`` / ``_entity_period_time_param``), or
1955
+ * pass ``dt`` to ``_filter_param_by_periods`` to restrict
1956
+ authored Map(period→…) values to the active periods.
1957
+
1958
+ On the fast path ``apply_direct_params_a`` runs first (with empty
1959
+ ``dt``), then ``apply_derived_a`` populates ``dt`` from the source's
1960
+ timeset / period structure, then this pass runs — every broadcast
1961
+ helper sees a non-empty ``dt`` and produces the per-(d, t) frame.
1962
+
1963
+ On the slow path ``flex_data.dt`` is already populated by ``_load_*``
1964
+ before ``_apply_db_overrides`` runs, so pass-A then pass-B is
1965
+ behaviourally equivalent to the legacy single pass.
1966
+ """
1967
+ # ─── Δ.4b — period filter (mirrors flextool's per-solve preprocessing) ─
1968
+ # Spine Map(period→…) parameters cover ALL declared periods, but the
1969
+ # CSV path's pd_group.csv / pdtNode.csv etc. is pre-filtered to the
1970
+ # active solve's periods via flextool's preprocessing. We mirror by
1971
+ # restricting period-keyed Params to the active dt's periods.
1972
+ dt = getattr(flex_data, "dt", None)
1973
+
1974
+ # ─── Δ.4b — group 1d_map(period) (capacity_margin / inertia / nonSync) ─
1975
+ # Δ.12b: unconditional — _entity_period_scalar handles 1d_map(period)
1976
+ # natively (this is the only Spine shape for these parameters).
1977
+ # Δ.12c-fix gap #3: pass ``dt`` as ``period_filter`` so scalar values
1978
+ # (single ``[name, value]`` row) broadcast across the active solve's
1979
+ # periods, matching flextool's pdGroup.csv preprocessing.
1980
+ for fn, field in (
1981
+ (pdGroup_capacity_margin_from_source, "pdGroup_capacity_margin"),
1982
+ (pdGroup_penalty_capacity_margin_from_source,
1983
+ "pdGroup_penalty_capacity_margin"),
1984
+ (pdGroup_inertia_limit_from_source, "pdGroup_inertia_limit"),
1985
+ (pdGroup_penalty_inertia_from_source, "pdGroup_penalty_inertia"),
1986
+ (pdGroup_non_synchronous_limit_from_source,
1987
+ "pdGroup_non_synchronous_limit"),
1988
+ (pdGroup_penalty_non_synchronous_from_source,
1989
+ "pdGroup_penalty_non_synchronous"),
1990
+ ):
1991
+ setattr(flex_data, field,
1992
+ _filter_param_by_periods(fn(source, period_filter=dt), dt))
1993
+
1994
+ # ─── Δ.4b — group 1d_map(period) (invest/divest/cumulative) ──────────
1995
+ # Δ.17c-Tier1 — helpers now accept ``period_filter`` (consumed by
1996
+ # ``resolve_param_shape`` for silent-default disambiguation and by
1997
+ # ``broadcast_to_period`` for the active-solve period restriction).
1998
+ for fn, field in (
1999
+ (p_group_invest_max_period_from_source, "p_group_invest_max_period"),
2000
+ (p_group_invest_min_period_from_source, "p_group_invest_min_period"),
2001
+ (p_group_retire_max_period_from_source, "p_group_retire_max_period"),
2002
+ (p_group_retire_min_period_from_source, "p_group_retire_min_period"),
2003
+ (pd_max_cumulative_flow_from_source, "pd_max_cumulative_flow"),
2004
+ (pd_min_cumulative_flow_from_source, "pd_min_cumulative_flow"),
2005
+ ):
2006
+ setattr(flex_data, field,
2007
+ _filter_param_by_periods(fn(source, period_filter=dt), dt))
2008
+
2009
+ # ─── Δ.4b — group scalar (no period) ─────────────────────────────────
2010
+ for fn, field in (
2011
+ (p_group_invest_max_total_from_source, "p_group_invest_max_total"),
2012
+ (p_group_invest_min_total_from_source, "p_group_invest_min_total"),
2013
+ (p_group_retire_max_total_from_source, "p_group_retire_max_total"),
2014
+ (p_group_retire_min_total_from_source, "p_group_retire_min_total"),
2015
+ (p_group_invest_max_cumulative_from_source,
2016
+ "p_group_invest_max_cumulative"),
2017
+ (p_group_invest_min_cumulative_from_source,
2018
+ "p_group_invest_min_cumulative"),
2019
+ (p_group_max_cumulative_flow_from_source, "p_group_max_cumulative_flow"),
2020
+ (p_group_min_cumulative_flow_from_source, "p_group_min_cumulative_flow"),
2021
+ ):
2022
+ setattr(flex_data, field, fn(source))
2023
+
2024
+ # ─── Δ.4b — group Map(period→time) instant flow caps ─────────────────
2025
+ # Δ.12c-fix gap #1: helpers now broadcast scalar / 1d_map(time) /
2026
+ # 1d_map(period) shapes across the active solve's (d, t) axis when
2027
+ # ``period_filter=dt`` is supplied. Conditional assignment retained
2028
+ # for fixtures with no Spine rows at all (helper returns None) where
2029
+ # the seed-side preprocessing produces an empty/zero-broadcast Param.
2030
+ v = _filter_param_by_periods(
2031
+ pdt_max_instant_flow_from_source(source, period_filter=dt), dt)
2032
+ if v is not None:
2033
+ flex_data.pdt_max_instant_flow = v
2034
+ v = _filter_param_by_periods(
2035
+ pdt_min_instant_flow_from_source(source, period_filter=dt), dt)
2036
+ if v is not None:
2037
+ flex_data.pdt_min_instant_flow = v
2038
+
2039
+ # ─── Δ.4b — multi-class union 1d_map(period): invest / divest ────────
2040
+ # Δ.17c-Tier2 — helpers now accept ``period_filter`` (consumed by
2041
+ # ``resolve_param_shape`` for silent-default disambiguation and by
2042
+ # ``broadcast_to_period`` for the active-solve period restriction and
2043
+ # scalar-to-(e,d) cross-join).
2044
+ for fn, field in (
2045
+ (ed_invest_max_period_from_source, "ed_invest_max_period"),
2046
+ (ed_divest_max_period_from_source, "ed_divest_max_period"),
2047
+ (ed_invest_min_period_from_source, "ed_invest_min_period"),
2048
+ (ed_divest_min_period_from_source, "ed_divest_min_period"),
2049
+ (ed_cumulative_max_capacity_from_source, "ed_cumulative_max_capacity"),
2050
+ (ed_cumulative_min_capacity_from_source, "ed_cumulative_min_capacity"),
2051
+ ):
2052
+ setattr(flex_data, field,
2053
+ _filter_param_by_periods(fn(source, period_filter=dt), dt))
2054
+
2055
+ # ─── Δ.4b — relationship scalars (ramp + inertia, sink/source) ───────
2056
+ for fn, field in (
2057
+ (p_ramp_speed_up_sink_from_source, "p_ramp_speed_up_sink"),
2058
+ (p_ramp_speed_down_sink_from_source, "p_ramp_speed_down_sink"),
2059
+ (p_ramp_speed_up_source_from_source, "p_ramp_speed_up_source"),
2060
+ (p_ramp_speed_down_source_from_source, "p_ramp_speed_down_source"),
2061
+ (p_process_sink_inertia_constant_from_source,
2062
+ "p_process_sink_inertia_constant"),
2063
+ (p_process_source_inertia_constant_from_source,
2064
+ "p_process_source_inertia_constant"),
2065
+ ):
2066
+ setattr(flex_data, field, fn(source))
2067
+
2068
+ # ─── Δ.4b — UC: startup_cost (1d_map period) ─────────────────────────
2069
+ # Δ.12c-fix gap #3: ``_entity_period_scalar`` handles 1d_map(period)
2070
+ # AND scalar shapes when ``period_filter=dt`` is supplied.
2071
+ v = _filter_param_by_periods(
2072
+ p_startup_cost_from_source(source, period_filter=dt), dt)
2073
+ if v is not None:
2074
+ flex_data.p_startup_cost = v
2075
+
2076
+ # ─── Δ.4b — storage Map(period→time) ─────────────────────────────────
2077
+ # Δ.12c-fix gap #1: helpers broadcast non-Map shapes via period_filter.
2078
+ v = _filter_param_by_periods(
2079
+ p_node_availability_from_source(source, period_filter=dt), dt)
2080
+ if v is not None:
2081
+ flex_data.p_node_availability = v
2082
+ v = _filter_param_by_periods(
2083
+ p_storage_state_reference_value_from_source(source, period_filter=dt),
2084
+ dt)
2085
+ if v is not None:
2086
+ flex_data.p_storage_state_reference_value = v
2087
+
2088
+ # ─── Δ.4b — CO2 (price + cap) ────────────────────────────────────────
2089
+ v = _filter_param_by_periods(
2090
+ p_co2_price_from_source(source, period_filter=dt), dt)
2091
+ if v is not None:
2092
+ flex_data.p_co2_price = v
2093
+ v = _filter_param_by_periods(
2094
+ p_co2_max_period_from_source(source, period_filter=dt), dt)
2095
+ if v is not None:
2096
+ flex_data.p_co2_max_period = v
2097
+
2098
+ # ─── Δ.4b — variable cost (Map period→time) ──────────────────────────
2099
+ # Δ.17c-Tier3 — the four helpers below route through the resolver
2100
+ # cascade (resolve_param_shape + broadcast_to_period_time). Each
2101
+ # accepts the active solve's (d, t) frame as ``period_filter`` so
2102
+ # SCALAR / MAP_PERIOD / MAP_TIME shapes broadcast up to (p, d, t).
2103
+ v = _filter_param_by_periods(
2104
+ p_pdt_varCost_source_from_source(source, period_filter=dt), dt)
2105
+ if v is not None:
2106
+ flex_data.p_pdt_varCost_source = v
2107
+ v = _filter_param_by_periods(
2108
+ p_pdt_varCost_sink_from_source(source, period_filter=dt), dt)
2109
+ if v is not None:
2110
+ flex_data.p_pdt_varCost_sink = v
2111
+ v = _filter_param_by_periods(
2112
+ p_pdt_varCost_process_from_source(source, period_filter=dt), dt)
2113
+ if v is not None:
2114
+ flex_data.p_pdt_varCost_process = v
2115
+
2116
+ # ─── Δ.4b — reserves ─────────────────────────────────────────────────
2117
+ # Δ.17c-Tier3 — pdtReserve_upDown_group_reservation routes through
2118
+ # the resolver cascade with a per-source-column rename dict (3-dim
2119
+ # relationship class). The relationship-scalar reserves below
2120
+ # (penalty / reliability / max_share / failure-ratio /
2121
+ # increase-reserve-ratio) stay direct — they're SCALAR-only.
2122
+ v = _filter_param_by_periods(
2123
+ pdtReserve_upDown_group_reservation_from_source(
2124
+ source, period_filter=dt), dt)
2125
+ if v is not None:
2126
+ flex_data.pdtReserve_upDown_group_reservation = v
2127
+ flex_data.p_reserve_upDown_group_penalty_reserve = (
2128
+ p_reserve_upDown_group_penalty_reserve_from_source(source))
2129
+ flex_data.p_process_reserve_upDown_node_reliability = (
2130
+ p_process_reserve_upDown_node_reliability_from_source(source))
2131
+ flex_data.p_process_reserve_upDown_node_max_share = (
2132
+ p_process_reserve_upDown_node_max_share_from_source(source))
2133
+ flex_data.p_process_reserve_upDown_node_large_failure_ratio_value = (
2134
+ p_process_reserve_upDown_node_large_failure_ratio_value_from_source(source))
2135
+ flex_data.p_process_reserve_upDown_node_increase_reserve_ratio_value = (
2136
+ p_process_reserve_upDown_node_increase_reserve_ratio_value_from_source(source))
2137
+
2138
+ # ─── Δ.4b — delayed processes (set, not Param) ───────────────────────
2139
+ flex_data.process_delayed__duration = process_delayed__duration_from_source(source)
2140
+
2141
+ # ─── Δ.4b — additional Map(period→time) on object classes ───────────
2142
+ # Δ.12c-fix gap #1: helpers broadcast non-Map shapes via period_filter.
2143
+ v = _filter_param_by_periods(
2144
+ p_process_availability_from_source(source, period_filter=dt), dt)
2145
+ if v is not None:
2146
+ flex_data.p_process_availability = v
2147
+ v = _filter_param_by_periods(
2148
+ p_commodity_price_from_source(source, period_filter=dt), dt)
2149
+ if v is not None:
2150
+ flex_data.p_commodity_price = v
2151
+
2152
+ # ─── Δ.4b — commodity ladder split (price / quantity) ────────────────
2153
+ # _ladder_split returns (price, quantity) — both can be None.
2154
+ p_ann_price, p_ann_qty = _ladder_split(source, "price_ladder_annual",
2155
+ with_period=True)
2156
+ if p_ann_price is not None:
2157
+ flex_data.p_ladder_ann_price = _filter_param_by_periods(p_ann_price, dt)
2158
+ if p_ann_qty is not None:
2159
+ flex_data.p_ladder_ann_quantity = _filter_param_by_periods(p_ann_qty, dt)
2160
+ p_cum_price, p_cum_qty = _ladder_split(source, "price_ladder_cumulative",
2161
+ with_period=False)
2162
+ if p_cum_price is not None:
2163
+ flex_data.p_ladder_cum_price = p_cum_price
2164
+ if p_cum_qty is not None:
2165
+ flex_data.p_ladder_cum_quantity = p_cum_qty
2166
+
2167
+
2168
+ def apply_direct_params(source: "InputSource",
2169
+ flex_data: object) -> None:
2170
+ """Apply the full Direct Param wave in two phases.
2171
+
2172
+ Δ.28 — kept as a single back-compat entry point. The dispatcher in
2173
+ :func:`flextool.engine_polars.input._apply_db_overrides` does NOT
2174
+ call this function any more; it calls
2175
+ :func:`apply_direct_params_a` / :func:`apply_direct_params_b`
2176
+ around :func:`apply_derived_a` so the dt-dependent broadcasts in
2177
+ pass 1b see a populated ``flex_data.dt``.
2178
+
2179
+ External callers that want the legacy single-pass behaviour (with
2180
+ pre-populated ``flex_data.dt``) keep this entry point. In the slow
2181
+ path ``flex_data.dt`` is loaded by ``_load_*`` before this runs;
2182
+ in the fast path the dispatcher's two-phase ordering is the only
2183
+ correct sequence.
2184
+ """
2185
+ apply_direct_params_a(source, flex_data)
2186
+ apply_direct_params_b(source, flex_data)