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,321 @@
1
+ """SpineDB → Provider pipeline validators.
2
+
3
+ Each validator consumes a :class:`spinedb_api.DatabaseMapping` (or a
4
+ :class:`SpineDBBackend` exposing one as ``._db``) and either:
5
+
6
+ * raises :class:`FlexToolConfigError` for hard misconfigurations that
7
+ would silently corrupt the solve (e.g. zero ``step_duration``), or
8
+ * logs a warning for soft inconsistencies (e.g. ``output_*: yes`` set
9
+ on a group with no members of the required class).
10
+
11
+ The validators live as their own module because:
12
+
13
+ 1. They are pure code paths with no derivation outputs — they don't
14
+ ``provider.put`` anything.
15
+ 2. They are called from :func:`flextool.input_derivation.run` early
16
+ enough that a hard failure surfaces before the cascade builds any
17
+ frames against malformed input.
18
+ 3. They share the :func:`_get_commodity_price_methods` helper but are
19
+ otherwise unrelated to the EAV → tabular materialiser specs in
20
+ :mod:`flextool.input_derivation._specs`.
21
+
22
+ Pre-Step-2.5 these functions lived in the 2356-LOC ``input_writer.py``
23
+ monolith; Step 2.5 item 6 extracted them here.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+
29
+ from flextool.engine_polars._solve_state import FlexToolConfigError
30
+
31
+
32
+ __all__ = [
33
+ "validate_timeline_timestep_duration",
34
+ "validate_capacity_margin_groups",
35
+ "validate_ladder_methods",
36
+ "validate_group_output_memberships",
37
+ "validate_connection_node_memberships",
38
+ ]
39
+
40
+
41
+ def _get_commodity_price_methods(db) -> dict[str, str]:
42
+ """Return ``{commodity: price_method}`` for every commodity whose
43
+ ``price_method`` is set. Commodities without the param default to
44
+ ``'price'`` in the mod (and do not appear here).
45
+ """
46
+ out: dict[str, str] = {}
47
+ for pv in db.find_parameter_values(
48
+ entity_class_name="commodity",
49
+ parameter_definition_name="price_method",
50
+ ):
51
+ if pv["type"] is None:
52
+ continue
53
+ out[pv["entity_byname"][0]] = str(pv["parsed_value"])
54
+ return out
55
+
56
+
57
+ def validate_timeline_timestep_duration(db) -> None:
58
+ """Raise FlexToolConfigError if any timeline entity is missing its
59
+ ``timestep_duration`` map. Without it, ``step_duration`` silently
60
+ falls to 0 throughout the model and every time-weighted quantity
61
+ (balances, costs, ramps) collapses to zero. There is no sensible
62
+ default, so the value must be present on every timeline.
63
+ """
64
+ timelines = [ent["entity_byname"][0]
65
+ for ent in db.find_entities(entity_class_name="timeline")]
66
+ if not timelines:
67
+ return
68
+ have_duration: set[str] = set()
69
+ for pv in db.find_parameter_values(
70
+ entity_class_name="timeline",
71
+ parameter_definition_name="timestep_duration",
72
+ ):
73
+ if pv["type"] is None:
74
+ continue
75
+ have_duration.add(pv["entity_byname"][0])
76
+ missing = [t for t in timelines if t not in have_duration]
77
+ if missing:
78
+ raise FlexToolConfigError(
79
+ "timeline 'timestep_duration' is not set for: "
80
+ + ", ".join(sorted(missing))
81
+ + ". Every timeline needs a Map(timestep -> duration_in_hours); "
82
+ "without it all time-weighted quantities collapse to zero."
83
+ )
84
+
85
+
86
+ def validate_capacity_margin_groups(db, logger: logging.Logger) -> None:
87
+ """Storage nodes are excluded from the capacity-margin constraint.
88
+ Raise if any capacity_margin_method='manual' group contains *only*
89
+ storage nodes (constraint would have no valid members); warn if a
90
+ mix is present.
91
+ """
92
+ capacity_margin_groups: dict[str, list[str]] = {}
93
+ for pv in db.find_parameter_values(
94
+ entity_class_name="group", parameter_definition_name="capacity_margin_method",
95
+ ):
96
+ if pv["parsed_value"] == "manual":
97
+ capacity_margin_groups[pv["entity_byname"][0]] = []
98
+ if not capacity_margin_groups:
99
+ return
100
+ for ent in db.find_entities(entity_class_name="group__node"):
101
+ g, n = ent["entity_byname"][0], ent["entity_byname"][1]
102
+ if g in capacity_margin_groups:
103
+ capacity_margin_groups[g].append(n)
104
+ storage_nodes: set[str] = set()
105
+ for pv in db.find_parameter_values(
106
+ entity_class_name="node", parameter_definition_name="node_type",
107
+ ):
108
+ if pv["parsed_value"] == "storage":
109
+ storage_nodes.add(pv["entity_byname"][0])
110
+ for g, nodes in capacity_margin_groups.items():
111
+ storage_in_group = [n for n in nodes if n in storage_nodes]
112
+ if storage_in_group and len(storage_in_group) == len(nodes):
113
+ raise FlexToolConfigError(
114
+ f"Capacity margin group '{g}' contains only storage nodes "
115
+ f"({', '.join(storage_in_group)}). The capacity margin constraint "
116
+ f"excludes storage nodes, so this group has no valid nodes."
117
+ )
118
+ elif storage_in_group:
119
+ logger.warning(
120
+ "Capacity margin group '%s' contains storage nodes (%s) which will "
121
+ "be excluded from the capacity margin constraint.",
122
+ g, ', '.join(storage_in_group),
123
+ )
124
+
125
+
126
+ def validate_ladder_methods(db, logger: logging.Logger) -> None:
127
+ """Raise FlexToolConfigError if any commodity declares a ladder
128
+ ``price_method`` but does not have the corresponding ladder parameter
129
+ set. Runs before the ladder writers so errors name the offending
130
+ commodity and expected parameter.
131
+ """
132
+ methods = _get_commodity_price_methods(db)
133
+ ladder_methods = {"price_ladder_annual", "price_ladder_cumulative"}
134
+ commodities_needing_ladder = {
135
+ c: m for c, m in methods.items() if m in ladder_methods
136
+ }
137
+ if not commodities_needing_ladder:
138
+ return
139
+
140
+ # Collect commodities that HAVE each ladder param (non-None, non-empty).
141
+ have_cumulative: set[str] = set()
142
+ have_annual: set[str] = set()
143
+ for pv in db.find_parameter_values(
144
+ entity_class_name="commodity",
145
+ parameter_definition_name="price_ladder_cumulative",
146
+ ):
147
+ if pv["type"] is None:
148
+ continue
149
+ have_cumulative.add(pv["entity_byname"][0])
150
+ for pv in db.find_parameter_values(
151
+ entity_class_name="commodity",
152
+ parameter_definition_name="price_ladder_annual",
153
+ ):
154
+ if pv["type"] is None:
155
+ continue
156
+ have_annual.add(pv["entity_byname"][0])
157
+
158
+ for commodity, method in commodities_needing_ladder.items():
159
+ expected_param = method # parameter name matches method name
160
+ if method == "price_ladder_cumulative" and commodity not in have_cumulative:
161
+ raise FlexToolConfigError(
162
+ f"commodity '{commodity}' has "
163
+ f"price_method='price_ladder_cumulative' but no "
164
+ f"'{expected_param}' value is set. Add a "
165
+ f"Map(tier -> {{price, quantity}}) on that parameter."
166
+ )
167
+ if method == "price_ladder_annual" and commodity not in have_annual:
168
+ raise FlexToolConfigError(
169
+ f"commodity '{commodity}' has "
170
+ f"price_method='price_ladder_annual' but no "
171
+ f"'{expected_param}' value is set. Add either a 2d "
172
+ f"Map(tier -> {{price, quantity}}) or a 3d "
173
+ f"Map(period -> Map(tier -> {{price, quantity}}))."
174
+ )
175
+
176
+
177
+ def validate_group_output_memberships(db, logger: logging.Logger) -> None:
178
+ """Warn when a group-level output flag is set but the group lacks
179
+ the membership class required for that output to produce any data.
180
+
181
+ Three silent-no-op cases are detected (v58 vocabulary):
182
+
183
+ * ``group.print_dispatch: yes`` with no ``group__node`` row
184
+ * ``group.print_indicators: yes`` with no ``group__node`` row
185
+ * ``flowGroup.flow_aggregator`` set to a non-``none`` method with no
186
+ ``flowGroup__unit__node`` **or** ``flowGroup__connection__node`` row
187
+
188
+ Additionally, a double-counting guard warns when a single flow arc
189
+ ``(process, node)`` belongs to two or more *dispatch-bound* flowGroups
190
+ (``flow_aggregator`` in ``{dispatch_plots_only, both}``): the arc would
191
+ be summed once per such flowGroup into the nodeGroup dispatch table.
192
+
193
+ Only warnings are emitted — a user may deliberately stage a partial
194
+ configuration.
195
+ """
196
+ # Collect groups that are members of the relevant entity classes.
197
+ groups_with_node_members: set[str] = set()
198
+ for ent in db.find_entities(entity_class_name="group__node"):
199
+ byname = ent["entity_byname"]
200
+ if byname:
201
+ groups_with_node_members.add(byname[0])
202
+
203
+ flowgroups_with_flow_members: set[str] = set()
204
+ for cls in ("flowGroup__unit__node", "flowGroup__connection__node"):
205
+ for ent in db.find_entities(entity_class_name=cls):
206
+ byname = ent["entity_byname"]
207
+ if byname:
208
+ flowgroups_with_flow_members.add(byname[0])
209
+
210
+ # nodeGroup output flags: "yes" on the ``group`` class, need group__node.
211
+ node_checks: list[tuple[str, str, set[str]]] = [
212
+ ("print_dispatch", "group__node", groups_with_node_members),
213
+ ("print_indicators", "group__node", groups_with_node_members),
214
+ ]
215
+ for param_name, required_members, membership_set in node_checks:
216
+ for pv in db.find_parameter_values(
217
+ entity_class_name="group", parameter_definition_name=param_name
218
+ ):
219
+ if pv["type"] is None:
220
+ continue
221
+ if pv["parsed_value"] != "yes":
222
+ continue
223
+ group_name = pv["entity_byname"][0]
224
+ if group_name not in membership_set:
225
+ logger.warning(
226
+ "Group '%s' has %s: yes but no %s members — output will be empty.",
227
+ group_name, param_name, required_members,
228
+ )
229
+
230
+ # flow_aggregator: an enum METHOD on the ``flowGroup`` class. Any value
231
+ # other than ``none`` requests dispatch bands and/or standalone
232
+ # indicators, both of which need flowGroup__*__node members to produce
233
+ # any data.
234
+ #
235
+ # We also record which flowGroups request dispatch *bands* — the values
236
+ # ``dispatch_plots_only`` and ``both`` — for the overlap check below.
237
+ _DISPATCH_BOUND = {"dispatch_plots_only", "both"}
238
+ dispatch_bound_flowgroups: set[str] = set()
239
+ for pv in db.find_parameter_values(
240
+ entity_class_name="flowGroup", parameter_definition_name="flow_aggregator"
241
+ ):
242
+ if pv["type"] is None:
243
+ continue
244
+ if pv["parsed_value"] in (None, "none"):
245
+ continue
246
+ group_name = pv["entity_byname"][0]
247
+ if pv["parsed_value"] in _DISPATCH_BOUND:
248
+ dispatch_bound_flowgroups.add(group_name)
249
+ if group_name not in flowgroups_with_flow_members:
250
+ logger.warning(
251
+ "flowGroup '%s' has flow_aggregator: %s but no "
252
+ "flowGroup__unit__node or flowGroup__connection__node members "
253
+ "— output will be empty.",
254
+ group_name, pv["parsed_value"],
255
+ )
256
+
257
+ # Double-counting guard: a single flow arc ``(process, node)`` that
258
+ # belongs to two (or more) dispatch-bound flowGroups (flow_aggregator in
259
+ # {dispatch_plots_only, both}) contributes its flow as a band in the
260
+ # nodeGroup dispatch table once per such flowGroup, so the dispatch
261
+ # balance double-counts that arc. Standalone-only overlap is legitimate
262
+ # (separate aggregator series), so it is excluded here. Only a warning
263
+ # is emitted — the user may have intended overlapping membership.
264
+ pn_to_aggregators: dict[tuple[str, str], list[str]] = {}
265
+ for cls in ("flowGroup__unit__node", "flowGroup__connection__node"):
266
+ for ent in db.find_entities(entity_class_name=cls):
267
+ byname = ent["entity_byname"]
268
+ if not byname or len(byname) < 3:
269
+ continue
270
+ flowgroup, process, node = byname[0], byname[1], byname[2]
271
+ if flowgroup not in dispatch_bound_flowgroups:
272
+ continue
273
+ pn_to_aggregators.setdefault((process, node), []).append(flowgroup)
274
+ for (process, node), flowgroups in pn_to_aggregators.items():
275
+ if len(flowgroups) >= 2:
276
+ logger.warning(
277
+ "Flow (%s, %s) belongs to multiple flowGroups (%s) in effect "
278
+ "double counting the flow in dispatch plots. Check "
279
+ "flow_aggregator parameter.",
280
+ process, node, ", ".join(sorted(flowgroups)),
281
+ )
282
+
283
+
284
+ def validate_connection_node_memberships(db, logger: logging.Logger) -> None:
285
+ """Warn when a ``connection`` entity has no ``connection__node__node``
286
+ relationship defining its two endpoints.
287
+
288
+ A connection's purpose is to transfer between two nodes; without a
289
+ ``connection__node__node`` member it carries no source/sink rows, never
290
+ enters ``process_source_sink``, and contributes nothing to any node
291
+ balance. If it nonetheless has an ``invest_method`` it still becomes a
292
+ (degenerate, always-zero) investment variable in the LP, surfacing as a
293
+ ``v_invest`` column the output post-processor has to tolerate (see
294
+ ``read_parameters._entity_universe``). ``db`` here is already
295
+ scenario-filtered, so this fires either because the connection was
296
+ never wired to its nodes, or because one of its endpoint nodes is not
297
+ active in the current scenario (so the ``connection__node__node`` row
298
+ is filtered out while the connection itself survives). We cannot tell
299
+ the two apart from the filtered set — the connection→node mapping
300
+ lived only in the now-absent ``connection__node__node`` row — so the
301
+ warning names both possibilities and tells the user to check the data.
302
+
303
+ Only a warning is emitted: the solve proceeds, treating the connection
304
+ as a no-op.
305
+ """
306
+ connected: set[str] = set()
307
+ for ent in db.find_entities(entity_class_name="connection__node__node"):
308
+ byname = ent["entity_byname"]
309
+ if byname:
310
+ connected.add(byname[0])
311
+ for ent in db.find_entities(entity_class_name="connection"):
312
+ byname = ent["entity_byname"]
313
+ if not byname:
314
+ continue
315
+ name = byname[0]
316
+ if name not in connected:
317
+ logger.warning(
318
+ "Connection '%s' excluded from this scenario: missing one/both endpoint "
319
+ "nodes or its connection__node__node — '%s' not included. Check your data!",
320
+ name, name,
321
+ )
@@ -0,0 +1,159 @@
1
+ """Lean parquet I/O: strip bulky pandas metadata, store compact MultiIndex info.
2
+
3
+ Standard ``df.to_parquet()`` embeds a large JSON blob (often 10-20 KB) in the
4
+ parquet footer so that pandas can perfectly round-trip MultiIndex structures.
5
+ For small summary tables this metadata can be 10-20x larger than the actual
6
+ data.
7
+
8
+ These helpers replace that blob with a ~60-byte ``flextool`` metadata entry
9
+ that records only the index and column level names — enough to reconstruct
10
+ both row and column MultiIndex on read. The resulting DataFrames are
11
+ completely normal pandas objects.
12
+
13
+ Backward-compatible: :func:`read_lean_parquet` detects old files that have
14
+ standard pandas metadata and falls back to ``pd.read_parquet()``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ import json
21
+ from pathlib import Path
22
+
23
+ import pandas as pd
24
+ import pyarrow as pa
25
+ import pyarrow.parquet as pq
26
+
27
+
28
+ def write_lean_parquet(
29
+ df: pd.DataFrame, path: str | Path, *, index: bool = True,
30
+ column_metadata: "dict | None" = None,
31
+ ) -> None:
32
+ """Write *df* to parquet with minimal metadata.
33
+
34
+ Parameters
35
+ ----------
36
+ df : DataFrame
37
+ The data to write. May have MultiIndex on rows, columns, or both.
38
+ path : str or Path
39
+ Destination file path.
40
+ index : bool
41
+ If ``True`` (default), preserve the row index. Pass ``False`` for
42
+ flat tables where the index is meaningless (e.g. timeline_breaks).
43
+ column_metadata : dict, optional
44
+ ``{leaf_column_name: {"unit", "semantics", "tooltip", "formula",
45
+ "docs"}}`` describing each logical column. Stored as a file-level
46
+ ``flextool_columns`` JSON blob (keyed by *leaf* name, robust to the
47
+ MultiIndex/scenario flattening that arrow applies to field names).
48
+ Read it back with :func:`read_lean_parquet_column_meta`.
49
+ """
50
+ # Don't preserve RangeIndex as a physical column — it carries no data
51
+ # and would create a stray ``__index_level_0__`` column without the
52
+ # pandas metadata blob to tell the reader to drop it.
53
+ preserve = index and not isinstance(df.index, pd.RangeIndex)
54
+ table = pa.Table.from_pandas(df, preserve_index=preserve)
55
+
56
+ info: dict[str, list | None] = {}
57
+
58
+ # Record row-index level names so we can reconstruct on read.
59
+ if preserve:
60
+ if isinstance(df.index, pd.MultiIndex):
61
+ info["idx"] = list(df.index.names)
62
+ else:
63
+ info["idx"] = [df.index.name]
64
+
65
+ # Record column index level names. For a MultiIndex we store the
66
+ # full list; for a plain single-level ``Index`` we store the single
67
+ # ``.name`` under a distinct key so the old MultiIndex reconstruction
68
+ # path is untouched. ``None`` names are skipped (nothing to restore).
69
+ if isinstance(df.columns, pd.MultiIndex):
70
+ info["col"] = list(df.columns.names)
71
+ elif df.columns.name is not None:
72
+ info["col_name"] = df.columns.name
73
+
74
+ # Swap the bulky pandas blob for our compact one.
75
+ meta = dict(table.schema.metadata or {})
76
+ meta.pop(b"pandas", None)
77
+ meta[b"flextool"] = json.dumps(info).encode()
78
+ if column_metadata:
79
+ meta[b"flextool_columns"] = json.dumps(column_metadata).encode()
80
+ table = table.replace_schema_metadata(meta)
81
+
82
+ pq.write_table(table, str(path))
83
+
84
+
85
+ def read_lean_parquet_column_meta(path: str | Path) -> dict | None:
86
+ """Return the per-column metadata blob written by :func:`write_lean_parquet`.
87
+
88
+ Reads only the file footer (cheap). Returns ``{leaf_column: {...}}`` or
89
+ ``None`` when the file carries no ``flextool_columns`` metadata.
90
+ """
91
+ file_meta = pq.ParquetFile(str(path)).schema_arrow.metadata or {}
92
+ blob = file_meta.get(b"flextool_columns")
93
+ return json.loads(blob) if blob else None
94
+
95
+
96
+ def read_lean_parquet(path: str | Path) -> pd.DataFrame:
97
+ """Read a parquet file, reconstructing any MultiIndex.
98
+
99
+ If the file contains ``flextool`` metadata (written by
100
+ :func:`write_lean_parquet`), uses the compact reconstruction logic.
101
+ Otherwise falls back to ``pd.read_parquet()`` for full backward
102
+ compatibility with files written by plain ``df.to_parquet()``.
103
+ """
104
+ path_str = str(path)
105
+
106
+ # Peek at file-level key-value metadata (cheap — reads only the footer).
107
+ pf = pq.ParquetFile(path_str)
108
+ file_meta = pf.schema_arrow.metadata or {}
109
+
110
+ if b"flextool" not in file_meta:
111
+ # Old-format file with standard pandas metadata.
112
+ return pd.read_parquet(path_str)
113
+
114
+ info: dict = json.loads(file_meta[b"flextool"])
115
+
116
+ # Read all columns as a flat DataFrame (no pandas metadata to guide it).
117
+ # pyarrow will create a default RangeIndex.
118
+ table = pq.read_table(path_str)
119
+ df = table.to_pandas()
120
+
121
+ # --- Reconstruct row index ---
122
+ idx_names: list | None = info.get("idx")
123
+ if idx_names:
124
+ # Map None level names to the __index_level_N__ placeholders that
125
+ # pyarrow uses when preserve_index=True encounters unnamed levels.
126
+ col_lookup: list[str] = []
127
+ for i, name in enumerate(idx_names):
128
+ if name is None:
129
+ col_lookup.append(f"__index_level_{i}__")
130
+ else:
131
+ col_lookup.append(name)
132
+
133
+ present = [c for c in col_lookup if c in df.columns]
134
+ if present:
135
+ df = df.set_index(present)
136
+ # Restore the original names (including None).
137
+ if isinstance(df.index, pd.MultiIndex):
138
+ df.index.names = idx_names
139
+ else:
140
+ df.index.name = idx_names[0]
141
+
142
+ # --- Reconstruct column MultiIndex ---
143
+ col_level_names: list | None = info.get("col")
144
+ if col_level_names:
145
+ # pyarrow stores MultiIndex column names as string representations
146
+ # of tuples — ``"('a', 'b')"`` for 2+ levels, ``"('a',)"`` for 1.
147
+ tuples = [ast.literal_eval(c) for c in df.columns]
148
+ df.columns = pd.MultiIndex.from_tuples(
149
+ tuples, names=col_level_names,
150
+ )
151
+ else:
152
+ # Single-level column Index — restore the ``.name`` if one was
153
+ # recorded at write time. Only touches files that actually carry
154
+ # the ``col_name`` field, so older parquets are unaffected.
155
+ single_col_name = info.get("col_name")
156
+ if single_col_name is not None:
157
+ df.columns.name = single_col_name
158
+
159
+ return df
@@ -0,0 +1,5 @@
1
+ """Mock-up scenario generator for FlexTool models."""
2
+
3
+ from flextool.model_builder.build_model import build_model
4
+
5
+ __all__ = ["build_model"]