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,1288 @@
1
+ """Read old-format FlexTool Excel files (.xlsm) and return structured data.
2
+
3
+ This module parses the 18-sheet .xlsm format used by earlier versions of FlexTool
4
+ and returns an OldFlexToolData dataclass containing all parsed information, ready
5
+ for database import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import openpyxl
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Sheets that should be skipped entirely
19
+ _SKIP_SHEETS = frozenset({
20
+ "info", "calc", "dropdown values", "Getting Started",
21
+ "Sensitivity scenarios", "Sensitivity definitions",
22
+ "Settings and filters", "Tool Information",
23
+ })
24
+
25
+ # Mapping from normalized unit-sheet column headers to UnitInstance field names.
26
+ # Headers not listed here go into extra_params.
27
+ _UNIT_COLUMN_MAP: dict[str, str] = {
28
+ "unitgroup": "unit_group",
29
+ "unit type": "unit_type",
30
+ "fuel": "fuel",
31
+ "cf profile": "cf_profile",
32
+ "inflow": "inflow_profile",
33
+ # Grid/node columns — may appear as separate columns or combined with "/"
34
+ "input grid": "input_grid",
35
+ "input node": "input_node",
36
+ "output grid": "output_grid",
37
+ "output node": "output_node",
38
+ "output2 grid": "output2_grid",
39
+ "output2 node": "output2_node",
40
+ # Capacity and investment
41
+ "capacity (mw)": "capacity_mw",
42
+ "invested capacity (mw)": "invested_capacity_mw",
43
+ "max invest (mw)": "max_invest_mw",
44
+ "storage (mwh)": "storage_mwh",
45
+ "invested storage (mwh)": "invested_storage_mwh",
46
+ "max invest (mwh)": "max_invest_mwh",
47
+ "storage start": "storage_start",
48
+ "storage start/finish": "storage_start",
49
+ "storage finish": "storage_finish",
50
+ "reserve increase ratio": "reserve_increase_ratio",
51
+ "use efficiency time series": "use_efficiency_ts",
52
+ # Output2 / CHP constraint coefficients
53
+ "output2 eq coeff": "output2_eq_coeff",
54
+ "output2 eq constant": "output2_eq_constant",
55
+ "output2 gt coeff": "output2_gt_coeff",
56
+ "output2 gt constant": "output2_gt_constant",
57
+ "output2 lt coeff": "output2_lt_coeff",
58
+ "output2 lt constant": "output2_lt_constant",
59
+ "output2 max capacity": "output2_max_capacity",
60
+ "output2 max capacity (mw)": "output2_max_capacity",
61
+ "output2 max capacity ratio": "output2_max_capacity",
62
+ "fueluse increase eq x output2": "fueluse_increase_eq",
63
+ "inflow multiplier": "inflow_multiplier",
64
+ }
65
+
66
+ # Columns that need special grid/node splitting
67
+ _UNIT_GRID_NODE_COLS: dict[str, tuple[str, str]] = {
68
+ "input grid/node": ("input_grid", "input_node"),
69
+ "output grid/node": ("output_grid", "output_node"),
70
+ "output2 grid/node": ("output2_grid", "output2_node"),
71
+ }
72
+
73
+ # String-typed fields in UnitInstance (everything else is float | None)
74
+ _UNIT_STRING_FIELDS = frozenset({
75
+ "unit_group", "unit_type", "fuel", "cf_profile", "inflow_profile",
76
+ "input_grid", "input_node", "output_grid", "output_node",
77
+ "output2_grid", "output2_node",
78
+ })
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Dataclasses
83
+ # ---------------------------------------------------------------------------
84
+
85
+ @dataclass
86
+ class SensitivityOverride:
87
+ """A single parameter override from the Sensitivity definitions sheet."""
88
+ section: str # "master", "nodeGroup", "gridNode", "unit_type", "fuel", "unitGroup", "units", "nodeNode"
89
+ entity_ids: dict[str, str] # entity identifier columns, e.g. {"nodeGroup": "reserve1"}
90
+ param_name: str # column header name, e.g. "co2_cost", "invested capacity (MW)"
91
+ value: float | str # the override value
92
+
93
+
94
+ @dataclass
95
+ class MasterParams:
96
+ """Global model parameters from 'master' sheet."""
97
+ params: dict[str, float]
98
+
99
+
100
+ @dataclass
101
+ class GridNode:
102
+ """A node definition from 'gridNode' sheet."""
103
+ grid: str
104
+ node: str
105
+ node_groups: list[str]
106
+ demand_mwh: float | None
107
+ import_mwh: float | None
108
+ capacity_margin_mw: float | None
109
+ non_synchronous_share: float | None
110
+ use_ts_reserve: float | None
111
+ use_dynamic_reserve: float | None
112
+ print_results: float | None
113
+
114
+
115
+ @dataclass
116
+ class NodeGroup:
117
+ """A node group from 'nodeGroup' sheet."""
118
+ name: str
119
+ capacity_margin_mw: float | None
120
+ non_synchronous_share: float | None
121
+ inertia_limit_mws: float | None
122
+ use_ts_reserve: float | None
123
+ use_dynamic_reserve: float | None
124
+
125
+
126
+ @dataclass
127
+ class UnitType:
128
+ """A technology template from 'unit_type' sheet."""
129
+ name: str
130
+ params: dict[str, float | None]
131
+
132
+
133
+ @dataclass
134
+ class Fuel:
135
+ """A fuel definition from 'fuel' sheet."""
136
+ name: str
137
+ price_per_mwh: float
138
+ co2_content: float
139
+
140
+
141
+ @dataclass
142
+ class UnitGroup:
143
+ """A unit group from 'unitGroup' sheet."""
144
+ name: str
145
+ max_invest_mw: float | None
146
+ min_invest_mw: float | None
147
+ max_invest_mwh: float | None
148
+ min_invest_mwh: float | None
149
+
150
+
151
+ @dataclass
152
+ class UnitInstance:
153
+ """A unit instance from 'units' sheet."""
154
+ unit_group: str | None
155
+ unit_type: str
156
+ fuel: str | None
157
+ cf_profile: str | None
158
+ inflow_profile: str | None
159
+ input_grid: str | None
160
+ input_node: str | None
161
+ output_grid: str | None
162
+ output_node: str | None
163
+ capacity_mw: float | None
164
+ invested_capacity_mw: float | None
165
+ max_invest_mw: float | None
166
+ storage_mwh: float | None
167
+ invested_storage_mwh: float | None
168
+ max_invest_mwh: float | None
169
+ storage_start: float | None
170
+ storage_finish: float | None
171
+ reserve_increase_ratio: float | None
172
+ use_efficiency_ts: float | None
173
+ output2_grid: str | None
174
+ output2_node: str | None
175
+ output2_eq_coeff: float | None
176
+ output2_eq_constant: float | None
177
+ output2_gt_coeff: float | None
178
+ output2_gt_constant: float | None
179
+ output2_lt_coeff: float | None
180
+ output2_lt_constant: float | None
181
+ output2_max_capacity: float | None
182
+ fueluse_increase_eq: float | None
183
+ inflow_multiplier: float | None
184
+ extra_params: dict[str, float | None] = field(default_factory=dict)
185
+
186
+
187
+ @dataclass
188
+ class NodeNodeConnection:
189
+ """A transmission connection from 'nodeNode' sheet."""
190
+ grid: str
191
+ node1: str
192
+ node2: str
193
+ cap_rightward_mw: float | None
194
+ cap_leftward_mw: float | None
195
+ invested_capacity_mw: float | None
196
+ max_invest_mw: float | None
197
+ loss: float | None
198
+ invest_cost_per_kw: float | None
199
+ lifetime: float | None
200
+ interest: float | None
201
+ is_hvdc: bool
202
+
203
+
204
+ @dataclass
205
+ class TimeSeriesData:
206
+ """A time series: dict mapping time_id (str) to value (float)."""
207
+ name: str
208
+ data: dict[str, float]
209
+
210
+
211
+ @dataclass
212
+ class UnitTimeSeries:
213
+ """Time series for a specific unit parameter."""
214
+ grid: str
215
+ node: str
216
+ unit: str
217
+ param_name: str
218
+ data: dict[str, float]
219
+
220
+
221
+ @dataclass
222
+ class DemandTimeSeries:
223
+ """Demand time series for a specific grid/node."""
224
+ grid: str
225
+ node: str
226
+ data: dict[str, float]
227
+
228
+
229
+ @dataclass
230
+ class TimeStep:
231
+ """A time step definition from 'ts_time' sheet."""
232
+ time_id: str
233
+ in_use: bool
234
+ time_jump: float
235
+ in_use_invest: bool
236
+ time_jump_invest: float
237
+
238
+
239
+ @dataclass
240
+ class OldFlexToolData:
241
+ """Complete parsed data from one old FlexTool .xlsm file."""
242
+ master: MasterParams
243
+ grid_nodes: list[GridNode]
244
+ node_groups: list[NodeGroup]
245
+ unit_types: list[UnitType]
246
+ fuels: list[Fuel]
247
+ unit_groups: list[UnitGroup]
248
+ units: list[UnitInstance]
249
+ connections: list[NodeNodeConnection]
250
+ cf_profiles: list[TimeSeriesData]
251
+ inflow_profiles: list[TimeSeriesData]
252
+ demand_ts: list[DemandTimeSeries]
253
+ import_ts: list[DemandTimeSeries]
254
+ reserve_node_ts: list[TimeSeriesData]
255
+ reserve_group_ts: list[TimeSeriesData]
256
+ unit_ts: list[UnitTimeSeries]
257
+ time_steps: list[TimeStep]
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Helpers
262
+ # ---------------------------------------------------------------------------
263
+
264
+ def _safe_float(value: Any) -> float | None:
265
+ """Convert a cell value to float, returning None for empty/non-numeric cells."""
266
+ if value is None:
267
+ return None
268
+ try:
269
+ return float(value)
270
+ except (ValueError, TypeError):
271
+ return None
272
+
273
+
274
+ def _safe_str(value: Any) -> str | None:
275
+ """Convert a cell value to a stripped string, returning None for empty cells."""
276
+ if value is None:
277
+ return None
278
+ s = str(value).strip()
279
+ return s if s else None
280
+
281
+
282
+ def _row_is_empty(row: tuple[Any, ...]) -> bool:
283
+ """Return True if every value in the row is None."""
284
+ return all(v is None for v in row)
285
+
286
+
287
+ def _split_grid_node(value: Any) -> tuple[str | None, str | None]:
288
+ """Split a 'grid/node' string into (grid, node). Returns (None, None) for empty."""
289
+ if value is None:
290
+ return None, None
291
+ s = str(value).strip()
292
+ if not s:
293
+ return None, None
294
+ if "/" in s:
295
+ parts = s.split("/", 1)
296
+ return parts[0].strip(), parts[1].strip()
297
+ return s, None
298
+
299
+
300
+ def _get_sheet(wb: openpyxl.Workbook, name: str) -> Any | None:
301
+ """Get a worksheet by name, returning None if it doesn't exist."""
302
+ if name in wb.sheetnames:
303
+ return wb[name]
304
+ logger.warning("Sheet '%s' not found in workbook; skipping.", name)
305
+ return None
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Sheet readers
310
+ # ---------------------------------------------------------------------------
311
+
312
+ def _read_master(wb: openpyxl.Workbook) -> MasterParams:
313
+ """Read the 'master' sheet into MasterParams."""
314
+ ws = _get_sheet(wb, "master")
315
+ params: dict[str, float] = {}
316
+ if ws is None:
317
+ return MasterParams(params=params)
318
+
319
+ first = True
320
+ for row in ws.iter_rows(values_only=True):
321
+ if first:
322
+ first = False
323
+ continue # skip header
324
+ if _row_is_empty(row):
325
+ continue
326
+ param_name = _safe_str(row[0]) if len(row) > 0 else None
327
+ raw_value = row[1] if len(row) > 1 else None
328
+ if param_name is not None:
329
+ val = _safe_float(raw_value)
330
+ if val is not None:
331
+ params[param_name] = val
332
+ else:
333
+ logger.warning("master: non-numeric value for '%s': %r", param_name, raw_value)
334
+ return MasterParams(params=params)
335
+
336
+
337
+ def _read_grid_nodes(wb: openpyxl.Workbook) -> list[GridNode]:
338
+ """Read the 'gridNode' sheet."""
339
+ ws = _get_sheet(wb, "gridNode")
340
+ if ws is None:
341
+ return []
342
+
343
+ rows = list(ws.iter_rows(values_only=True))
344
+ if not rows:
345
+ return []
346
+
347
+ # Header row
348
+ headers = [_safe_str(h) for h in rows[0]]
349
+ result: list[GridNode] = []
350
+
351
+ def _col(name: str) -> int | None:
352
+ """Find column index by header name (case-insensitive)."""
353
+ name_lower = name.lower()
354
+ for i, h in enumerate(headers):
355
+ if h is not None and h.lower() == name_lower:
356
+ return i
357
+ return None
358
+
359
+ def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
360
+ if col_idx is None or col_idx >= len(row):
361
+ return None
362
+ return row[col_idx]
363
+
364
+ c_grid = _col("grid")
365
+ c_node = _col("node")
366
+ c_ng1 = _col("nodeGroup")
367
+ c_ng2 = _col("nodeGroup2")
368
+ c_ng3 = _col("nodeGroup3")
369
+ c_demand = _col("demand (MWh)")
370
+ c_import = _col("import (MWh)")
371
+ c_cap_margin = _col("capacity margin (MW)")
372
+ c_nonsync = _col("non synchronous share")
373
+ c_ts_reserve = _col("use ts_reserve")
374
+ c_dyn_reserve = _col("use dynamic reserve")
375
+ c_print = _col("print results")
376
+
377
+ for row in rows[1:]:
378
+ if _row_is_empty(row):
379
+ continue
380
+ grid = _safe_str(_get(row, c_grid))
381
+ node = _safe_str(_get(row, c_node))
382
+ if grid is None or node is None:
383
+ continue
384
+
385
+ groups: list[str] = []
386
+ for c in (c_ng1, c_ng2, c_ng3):
387
+ g = _safe_str(_get(row, c))
388
+ if g:
389
+ groups.append(g)
390
+
391
+ result.append(GridNode(
392
+ grid=grid,
393
+ node=node,
394
+ node_groups=groups,
395
+ demand_mwh=_safe_float(_get(row, c_demand)),
396
+ import_mwh=_safe_float(_get(row, c_import)),
397
+ capacity_margin_mw=_safe_float(_get(row, c_cap_margin)),
398
+ non_synchronous_share=_safe_float(_get(row, c_nonsync)),
399
+ use_ts_reserve=_safe_float(_get(row, c_ts_reserve)),
400
+ use_dynamic_reserve=_safe_float(_get(row, c_dyn_reserve)),
401
+ print_results=_safe_float(_get(row, c_print)),
402
+ ))
403
+ return result
404
+
405
+
406
+ def _read_node_groups(wb: openpyxl.Workbook) -> list[NodeGroup]:
407
+ """Read the 'nodeGroup' sheet."""
408
+ ws = _get_sheet(wb, "nodeGroup")
409
+ if ws is None:
410
+ return []
411
+
412
+ rows = list(ws.iter_rows(values_only=True))
413
+ if not rows:
414
+ return []
415
+
416
+ headers = [_safe_str(h) for h in rows[0]]
417
+ result: list[NodeGroup] = []
418
+
419
+ def _col(name: str) -> int | None:
420
+ name_lower = name.lower()
421
+ for i, h in enumerate(headers):
422
+ if h is not None and h.lower() == name_lower:
423
+ return i
424
+ return None
425
+
426
+ def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
427
+ if col_idx is None or col_idx >= len(row):
428
+ return None
429
+ return row[col_idx]
430
+
431
+ c_name = _col("nodeGroup")
432
+ c_cap = _col("capacity margin (MW)")
433
+ c_nonsync = _col("non synchronous share")
434
+ c_inertia = _col("inertia limit (MWs)")
435
+ c_ts_reserve = _col("use ts_reserve")
436
+ c_dyn_reserve = _col("use dynamic reserve")
437
+
438
+ for row in rows[1:]:
439
+ if _row_is_empty(row):
440
+ continue
441
+ name = _safe_str(_get(row, c_name))
442
+ if name is None:
443
+ continue
444
+ result.append(NodeGroup(
445
+ name=name,
446
+ capacity_margin_mw=_safe_float(_get(row, c_cap)),
447
+ non_synchronous_share=_safe_float(_get(row, c_nonsync)),
448
+ inertia_limit_mws=_safe_float(_get(row, c_inertia)),
449
+ use_ts_reserve=_safe_float(_get(row, c_ts_reserve)),
450
+ use_dynamic_reserve=_safe_float(_get(row, c_dyn_reserve)),
451
+ ))
452
+ return result
453
+
454
+
455
+ def _read_unit_types(wb: openpyxl.Workbook) -> list[UnitType]:
456
+ """Read the 'unit_type' sheet."""
457
+ ws = _get_sheet(wb, "unit_type")
458
+ if ws is None:
459
+ return []
460
+
461
+ rows = list(ws.iter_rows(values_only=True))
462
+ if not rows:
463
+ return []
464
+
465
+ headers = [_safe_str(h) for h in rows[0]]
466
+ result: list[UnitType] = []
467
+
468
+ # First column is the unit type name; remaining columns are parameters
469
+ for row in rows[1:]:
470
+ if _row_is_empty(row):
471
+ continue
472
+ name = _safe_str(row[0]) if len(row) > 0 else None
473
+ if name is None:
474
+ continue
475
+ params: dict[str, float | None] = {}
476
+ for i in range(1, min(len(headers), len(row))):
477
+ header = headers[i]
478
+ if header is not None:
479
+ key = header.strip().lower()
480
+ params[key] = _safe_float(row[i])
481
+ result.append(UnitType(name=name, params=params))
482
+ return result
483
+
484
+
485
+ def _read_fuels(wb: openpyxl.Workbook) -> list[Fuel]:
486
+ """Read the 'fuel' sheet."""
487
+ ws = _get_sheet(wb, "fuel")
488
+ if ws is None:
489
+ return []
490
+
491
+ rows = list(ws.iter_rows(values_only=True))
492
+ if not rows:
493
+ return []
494
+
495
+ result: list[Fuel] = []
496
+ for row in rows[1:]:
497
+ if _row_is_empty(row):
498
+ continue
499
+ name = _safe_str(row[0]) if len(row) > 0 else None
500
+ if name is None:
501
+ continue
502
+ price = _safe_float(row[1] if len(row) > 1 else None)
503
+ co2 = _safe_float(row[2] if len(row) > 2 else None)
504
+ result.append(Fuel(
505
+ name=name,
506
+ price_per_mwh=price if price is not None else 0.0,
507
+ co2_content=co2 if co2 is not None else 0.0,
508
+ ))
509
+ return result
510
+
511
+
512
+ def _read_unit_groups(wb: openpyxl.Workbook) -> list[UnitGroup]:
513
+ """Read the 'unitGroup' sheet."""
514
+ ws = _get_sheet(wb, "unitGroup")
515
+ if ws is None:
516
+ return []
517
+
518
+ rows = list(ws.iter_rows(values_only=True))
519
+ if not rows:
520
+ return []
521
+
522
+ headers = [_safe_str(h) for h in rows[0]]
523
+ result: list[UnitGroup] = []
524
+
525
+ def _col(name: str) -> int | None:
526
+ name_lower = name.lower()
527
+ for i, h in enumerate(headers):
528
+ if h is not None and h.lower() == name_lower:
529
+ return i
530
+ return None
531
+
532
+ def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
533
+ if col_idx is None or col_idx >= len(row):
534
+ return None
535
+ return row[col_idx]
536
+
537
+ c_name = _col("unitGroup")
538
+ c_max_mw = _col("max invest MW")
539
+ c_min_mw = _col("min invest MW")
540
+ c_max_mwh = _col("max invest MWh")
541
+ c_min_mwh = _col("min invest MWh")
542
+
543
+ for row in rows[1:]:
544
+ if _row_is_empty(row):
545
+ continue
546
+ name = _safe_str(_get(row, c_name))
547
+ if name is None:
548
+ continue
549
+ result.append(UnitGroup(
550
+ name=name,
551
+ max_invest_mw=_safe_float(_get(row, c_max_mw)),
552
+ min_invest_mw=_safe_float(_get(row, c_min_mw)),
553
+ max_invest_mwh=_safe_float(_get(row, c_max_mwh)),
554
+ min_invest_mwh=_safe_float(_get(row, c_min_mwh)),
555
+ ))
556
+ return result
557
+
558
+
559
+ def _read_units(wb: openpyxl.Workbook) -> list[UnitInstance]:
560
+ """Read the 'units' sheet."""
561
+ ws = _get_sheet(wb, "units")
562
+ if ws is None:
563
+ return []
564
+
565
+ rows = list(ws.iter_rows(values_only=True))
566
+ if not rows:
567
+ return []
568
+
569
+ # Build header-to-index mapping
570
+ raw_headers = rows[0]
571
+ headers: list[str | None] = [_safe_str(h) for h in raw_headers]
572
+ normalized_headers: list[str | None] = [
573
+ h.strip().lower() if h is not None else None for h in headers
574
+ ]
575
+
576
+ result: list[UnitInstance] = []
577
+
578
+ for row in rows[1:]:
579
+ # Stop reading when all values in a row are None
580
+ if _row_is_empty(row):
581
+ continue
582
+
583
+ # Build a dict of field_name -> value for this row
584
+ field_values: dict[str, Any] = {
585
+ "unit_group": None,
586
+ "unit_type": "",
587
+ "fuel": None,
588
+ "cf_profile": None,
589
+ "inflow_profile": None,
590
+ "input_grid": None,
591
+ "input_node": None,
592
+ "output_grid": None,
593
+ "output_node": None,
594
+ "capacity_mw": None,
595
+ "invested_capacity_mw": None,
596
+ "max_invest_mw": None,
597
+ "storage_mwh": None,
598
+ "invested_storage_mwh": None,
599
+ "max_invest_mwh": None,
600
+ "storage_start": None,
601
+ "storage_finish": None,
602
+ "reserve_increase_ratio": None,
603
+ "use_efficiency_ts": None,
604
+ "output2_grid": None,
605
+ "output2_node": None,
606
+ "output2_eq_coeff": None,
607
+ "output2_eq_constant": None,
608
+ "output2_gt_coeff": None,
609
+ "output2_gt_constant": None,
610
+ "output2_lt_coeff": None,
611
+ "output2_lt_constant": None,
612
+ "output2_max_capacity": None,
613
+ "fueluse_increase_eq": None,
614
+ "inflow_multiplier": None,
615
+ }
616
+ extra: dict[str, float | None] = {}
617
+
618
+ for col_idx, norm_h in enumerate(normalized_headers):
619
+ if norm_h is None or col_idx >= len(row):
620
+ continue
621
+ cell_value = row[col_idx]
622
+
623
+ # Check for combined grid/node columns (e.g. "input grid/node")
624
+ if norm_h in _UNIT_GRID_NODE_COLS:
625
+ grid_field, node_field = _UNIT_GRID_NODE_COLS[norm_h]
626
+ g, n = _split_grid_node(cell_value)
627
+ field_values[grid_field] = g
628
+ field_values[node_field] = n
629
+ elif norm_h in _UNIT_COLUMN_MAP:
630
+ field_name = _UNIT_COLUMN_MAP[norm_h]
631
+ if field_name in _UNIT_STRING_FIELDS:
632
+ field_values[field_name] = _safe_str(cell_value)
633
+ else:
634
+ field_values[field_name] = _safe_float(cell_value)
635
+ else:
636
+ # Extra / unknown columns
637
+ original_header = headers[col_idx]
638
+ if original_header is not None:
639
+ extra[original_header] = _safe_float(cell_value)
640
+
641
+ # Skip rows where unit_type is missing
642
+ if not field_values["unit_type"]:
643
+ continue
644
+
645
+ result.append(UnitInstance(
646
+ unit_group=field_values["unit_group"],
647
+ unit_type=field_values["unit_type"],
648
+ fuel=field_values["fuel"],
649
+ cf_profile=field_values["cf_profile"],
650
+ inflow_profile=field_values["inflow_profile"],
651
+ input_grid=field_values["input_grid"],
652
+ input_node=field_values["input_node"],
653
+ output_grid=field_values["output_grid"],
654
+ output_node=field_values["output_node"],
655
+ capacity_mw=field_values["capacity_mw"],
656
+ invested_capacity_mw=field_values["invested_capacity_mw"],
657
+ max_invest_mw=field_values["max_invest_mw"],
658
+ storage_mwh=field_values["storage_mwh"],
659
+ invested_storage_mwh=field_values["invested_storage_mwh"],
660
+ max_invest_mwh=field_values["max_invest_mwh"],
661
+ storage_start=field_values["storage_start"],
662
+ storage_finish=field_values["storage_finish"],
663
+ reserve_increase_ratio=field_values["reserve_increase_ratio"],
664
+ use_efficiency_ts=field_values["use_efficiency_ts"],
665
+ output2_grid=field_values["output2_grid"],
666
+ output2_node=field_values["output2_node"],
667
+ output2_eq_coeff=field_values["output2_eq_coeff"],
668
+ output2_eq_constant=field_values["output2_eq_constant"],
669
+ output2_gt_coeff=field_values["output2_gt_coeff"],
670
+ output2_gt_constant=field_values["output2_gt_constant"],
671
+ output2_lt_coeff=field_values["output2_lt_coeff"],
672
+ output2_lt_constant=field_values["output2_lt_constant"],
673
+ output2_max_capacity=field_values["output2_max_capacity"],
674
+ fueluse_increase_eq=field_values["fueluse_increase_eq"],
675
+ inflow_multiplier=field_values["inflow_multiplier"],
676
+ extra_params=extra,
677
+ ))
678
+ return result
679
+
680
+
681
+ def _read_connections(wb: openpyxl.Workbook) -> list[NodeNodeConnection]:
682
+ """Read the 'nodeNode' sheet."""
683
+ ws = _get_sheet(wb, "nodeNode")
684
+ if ws is None:
685
+ return []
686
+
687
+ rows = list(ws.iter_rows(values_only=True))
688
+ if not rows:
689
+ return []
690
+
691
+ headers = [_safe_str(h) for h in rows[0]]
692
+ result: list[NodeNodeConnection] = []
693
+
694
+ def _col(name: str) -> int | None:
695
+ name_lower = name.lower()
696
+ for i, h in enumerate(headers):
697
+ if h is not None and h.lower() == name_lower:
698
+ return i
699
+ return None
700
+
701
+ def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
702
+ if col_idx is None or col_idx >= len(row):
703
+ return None
704
+ return row[col_idx]
705
+
706
+ c_grid = _col("grid")
707
+ c_n1 = _col("node1")
708
+ c_n2 = _col("node2")
709
+ c_cap_r = _col("cap.rightward (MW)")
710
+ c_cap_l = _col("cap.leftward (MW)")
711
+ c_inv_cap = _col("invested capacity (MW)")
712
+ c_max_inv = _col("max invest (MW)")
713
+ c_loss = _col("loss")
714
+ c_inv_cost = _col("inv.cost/kW")
715
+ c_life = _col("lifetime")
716
+ c_interest = _col("interest")
717
+ _col("annuity")
718
+ c_hvdc = _col("HVDC")
719
+
720
+ for row in rows[1:]:
721
+ if _row_is_empty(row):
722
+ continue
723
+ grid = _safe_str(_get(row, c_grid))
724
+ n1 = _safe_str(_get(row, c_n1))
725
+ n2 = _safe_str(_get(row, c_n2))
726
+ if grid is None or n1 is None or n2 is None:
727
+ continue
728
+
729
+ hvdc_val = _safe_float(_get(row, c_hvdc))
730
+ is_hvdc = bool(hvdc_val) if hvdc_val is not None else False
731
+
732
+ result.append(NodeNodeConnection(
733
+ grid=grid,
734
+ node1=n1,
735
+ node2=n2,
736
+ cap_rightward_mw=_safe_float(_get(row, c_cap_r)),
737
+ cap_leftward_mw=_safe_float(_get(row, c_cap_l)),
738
+ invested_capacity_mw=_safe_float(_get(row, c_inv_cap)),
739
+ max_invest_mw=_safe_float(_get(row, c_max_inv)),
740
+ loss=_safe_float(_get(row, c_loss)),
741
+ invest_cost_per_kw=_safe_float(_get(row, c_inv_cost)),
742
+ lifetime=_safe_float(_get(row, c_life)),
743
+ interest=_safe_float(_get(row, c_interest)),
744
+ is_hvdc=is_hvdc,
745
+ ))
746
+ return result
747
+
748
+
749
+ def _read_profile_ts(
750
+ wb: openpyxl.Workbook,
751
+ sheet_name: str,
752
+ ) -> list[TimeSeriesData]:
753
+ """Read a profile time-series sheet (ts_cf, ts_inflow).
754
+
755
+ Layout:
756
+ Row 0: col 1 has a label, cols 2+ have profile names.
757
+ Row 1: col 0 has "time" / "Time".
758
+ Rows 2+: col 0 has time ids, cols 2+ have values.
759
+ """
760
+ ws = _get_sheet(wb, sheet_name)
761
+ if ws is None:
762
+ return []
763
+
764
+ rows = list(ws.iter_rows(values_only=True))
765
+ if len(rows) < 3:
766
+ logger.warning("Sheet '%s' has fewer than 3 rows; skipping.", sheet_name)
767
+ return []
768
+
769
+ # Row 0: profile names starting at col index that has data
770
+ header_row = rows[0]
771
+ # Determine the first data column (skip cols 0 and possibly 1 if it's a label)
772
+ profile_names: dict[int, str] = {}
773
+ for col_idx in range(1, len(header_row)):
774
+ name = _safe_str(header_row[col_idx])
775
+ if name is not None:
776
+ profile_names[col_idx] = name
777
+
778
+ if not profile_names:
779
+ logger.warning("Sheet '%s': no profile names found in header row.", sheet_name)
780
+ return []
781
+
782
+ # Determine the starting data column: smallest col_idx that has a profile name
783
+ # But the actual profile names may be labels in col 1 -- find the first numeric col
784
+ first_data_col = min(profile_names.keys())
785
+
786
+ # Build time series, skipping header rows (rows 0 and 1)
787
+ series_data: dict[int, dict[str, float]] = {ci: {} for ci in profile_names}
788
+
789
+ for row in rows[2:]:
790
+ if _row_is_empty(row):
791
+ continue
792
+ time_id = _safe_str(row[0]) if len(row) > 0 else None
793
+ if time_id is None:
794
+ continue
795
+ for col_idx in profile_names:
796
+ if col_idx < len(row):
797
+ val = _safe_float(row[col_idx])
798
+ if val is not None:
799
+ series_data[col_idx][time_id] = val
800
+
801
+ result: list[TimeSeriesData] = []
802
+ for col_idx, name in sorted(profile_names.items()):
803
+ # Skip if name looks like a label for the header row itself
804
+ # (e.g. "cf_profile", "inflow") -- these are column 1 labels, not profile names
805
+ if col_idx == first_data_col and first_data_col == 1:
806
+ # Check if there are profiles at higher indices; if so, col 1 is likely a label
807
+ if len(profile_names) > 1:
808
+ continue
809
+ data = series_data[col_idx]
810
+ if data:
811
+ result.append(TimeSeriesData(name=name, data=data))
812
+ return result
813
+
814
+
815
+ def _read_demand_ts(
816
+ wb: openpyxl.Workbook,
817
+ sheet_name: str,
818
+ ) -> list[DemandTimeSeries]:
819
+ """Read a demand-style time-series sheet (ts_energy, ts_import).
820
+
821
+ Layout:
822
+ Row 0: col 1 has "grid", cols 2+ have grid names.
823
+ Row 1: col 1 has "node", cols 2+ have node names.
824
+ Row 2: col 0 has "time".
825
+ Rows 3+: col 0 has time ids, cols 2+ have values.
826
+ """
827
+ ws = _get_sheet(wb, sheet_name)
828
+ if ws is None:
829
+ return []
830
+
831
+ rows = list(ws.iter_rows(values_only=True))
832
+ if len(rows) < 4:
833
+ logger.warning("Sheet '%s' has fewer than 4 rows; skipping.", sheet_name)
834
+ return []
835
+
836
+ grid_row = rows[0]
837
+ node_row = rows[1]
838
+
839
+ # Build column -> (grid, node) mapping
840
+ col_map: dict[int, tuple[str, str]] = {}
841
+ for col_idx in range(2, max(len(grid_row), len(node_row))):
842
+ grid = _safe_str(grid_row[col_idx]) if col_idx < len(grid_row) else None
843
+ node = _safe_str(node_row[col_idx]) if col_idx < len(node_row) else None
844
+ if grid is not None and node is not None:
845
+ col_map[col_idx] = (grid, node)
846
+
847
+ if not col_map:
848
+ logger.warning("Sheet '%s': no grid/node pairs found.", sheet_name)
849
+ return []
850
+
851
+ # Read data rows (row 3+)
852
+ series_data: dict[int, dict[str, float]] = {ci: {} for ci in col_map}
853
+
854
+ for row in rows[3:]:
855
+ if _row_is_empty(row):
856
+ continue
857
+ time_id = _safe_str(row[0]) if len(row) > 0 else None
858
+ if time_id is None:
859
+ continue
860
+ for col_idx in col_map:
861
+ if col_idx < len(row):
862
+ val = _safe_float(row[col_idx])
863
+ if val is not None:
864
+ series_data[col_idx][time_id] = val
865
+
866
+ result: list[DemandTimeSeries] = []
867
+ for col_idx, (grid, node) in sorted(col_map.items()):
868
+ data = series_data[col_idx]
869
+ if data:
870
+ result.append(DemandTimeSeries(grid=grid, node=node, data=data))
871
+ return result
872
+
873
+
874
+ def _read_reserve_ts(
875
+ wb: openpyxl.Workbook,
876
+ sheet_name: str,
877
+ ) -> list[TimeSeriesData]:
878
+ """Read a reserve time-series sheet (ts_reserve_node, ts_reserve_nodeGroup).
879
+
880
+ Layout:
881
+ Row 0: col 1 has "node"/"nodeGroup", cols 2+ have names.
882
+ Row 1: col 0 has "Time"/"time".
883
+ Rows 2+: col 0 has time ids, cols 2+ have values.
884
+ """
885
+ ws = _get_sheet(wb, sheet_name)
886
+ if ws is None:
887
+ return []
888
+
889
+ rows = list(ws.iter_rows(values_only=True))
890
+ if len(rows) < 3:
891
+ logger.warning("Sheet '%s' has fewer than 3 rows; skipping.", sheet_name)
892
+ return []
893
+
894
+ header_row = rows[0]
895
+ # Profile names at col 2+
896
+ name_map: dict[int, str] = {}
897
+ for col_idx in range(2, len(header_row)):
898
+ name = _safe_str(header_row[col_idx])
899
+ if name is not None:
900
+ name_map[col_idx] = name
901
+
902
+ if not name_map:
903
+ # Try starting from col 1 in case there's no label col
904
+ for col_idx in range(1, len(header_row)):
905
+ name = _safe_str(header_row[col_idx])
906
+ if name is not None:
907
+ name_map[col_idx] = name
908
+
909
+ if not name_map:
910
+ logger.warning("Sheet '%s': no names found in header row.", sheet_name)
911
+ return []
912
+
913
+ series_data: dict[int, dict[str, float]] = {ci: {} for ci in name_map}
914
+
915
+ for row in rows[2:]:
916
+ if _row_is_empty(row):
917
+ continue
918
+ time_id = _safe_str(row[0]) if len(row) > 0 else None
919
+ if time_id is None:
920
+ continue
921
+ for col_idx in name_map:
922
+ if col_idx < len(row):
923
+ val = _safe_float(row[col_idx])
924
+ if val is not None:
925
+ series_data[col_idx][time_id] = val
926
+
927
+ result: list[TimeSeriesData] = []
928
+ for col_idx, name in sorted(name_map.items()):
929
+ data = series_data[col_idx]
930
+ if data:
931
+ result.append(TimeSeriesData(name=name, data=data))
932
+ return result
933
+
934
+
935
+ def _read_unit_ts(wb: openpyxl.Workbook) -> list[UnitTimeSeries]:
936
+ """Read the 'ts_unit' sheet.
937
+
938
+ Layout:
939
+ Row 0: col 1 has "grid", cols 2+ have grid names.
940
+ Row 1: col 1 has "node", cols 2+ have node names.
941
+ Row 2: col 1 has "unit", cols 2+ have unit names.
942
+ Row 3: col 1 has "unit_ts_param", cols 2+ have parameter names.
943
+ Row 4: col 0 has "Time".
944
+ Rows 5+: col 0 has time ids, cols 2+ have values.
945
+ """
946
+ ws = _get_sheet(wb, "ts_unit")
947
+ if ws is None:
948
+ return []
949
+
950
+ rows = list(ws.iter_rows(values_only=True))
951
+ if len(rows) < 6:
952
+ logger.warning("Sheet 'ts_unit' has fewer than 6 rows; skipping.")
953
+ return []
954
+
955
+ grid_row = rows[0]
956
+ node_row = rows[1]
957
+ unit_row = rows[2]
958
+ param_row = rows[3]
959
+
960
+ # Build column -> (grid, node, unit, param) mapping
961
+ col_map: dict[int, tuple[str, str, str, str]] = {}
962
+ max_len = max(len(grid_row), len(node_row), len(unit_row), len(param_row))
963
+ for col_idx in range(2, max_len):
964
+ grid = _safe_str(grid_row[col_idx]) if col_idx < len(grid_row) else None
965
+ node = _safe_str(node_row[col_idx]) if col_idx < len(node_row) else None
966
+ unit = _safe_str(unit_row[col_idx]) if col_idx < len(unit_row) else None
967
+ param = _safe_str(param_row[col_idx]) if col_idx < len(param_row) else None
968
+ if grid is not None and node is not None and unit is not None and param is not None:
969
+ col_map[col_idx] = (grid, node, unit, param)
970
+
971
+ if not col_map:
972
+ logger.warning("Sheet 'ts_unit': no unit time series columns found.")
973
+ return []
974
+
975
+ series_data: dict[int, dict[str, float]] = {ci: {} for ci in col_map}
976
+
977
+ for row in rows[5:]:
978
+ if _row_is_empty(row):
979
+ continue
980
+ time_id = _safe_str(row[0]) if len(row) > 0 else None
981
+ if time_id is None:
982
+ continue
983
+ for col_idx in col_map:
984
+ if col_idx < len(row):
985
+ val = _safe_float(row[col_idx])
986
+ if val is not None:
987
+ series_data[col_idx][time_id] = val
988
+
989
+ result: list[UnitTimeSeries] = []
990
+ for col_idx, (grid, node, unit, param) in sorted(col_map.items()):
991
+ data = series_data[col_idx]
992
+ if data:
993
+ result.append(UnitTimeSeries(
994
+ grid=grid, node=node, unit=unit, param_name=param, data=data,
995
+ ))
996
+ return result
997
+
998
+
999
+ def _read_time_steps(wb: openpyxl.Workbook) -> list[TimeStep]:
1000
+ """Read the 'ts_time' sheet."""
1001
+ ws = _get_sheet(wb, "ts_time")
1002
+ if ws is None:
1003
+ return []
1004
+
1005
+ rows = list(ws.iter_rows(values_only=True))
1006
+ if not rows:
1007
+ return []
1008
+
1009
+ headers = [_safe_str(h) for h in rows[0]]
1010
+ result: list[TimeStep] = []
1011
+
1012
+ def _col(name: str) -> int | None:
1013
+ name_lower = name.lower()
1014
+ for i, h in enumerate(headers):
1015
+ if h is not None and h.lower() == name_lower:
1016
+ return i
1017
+ return None
1018
+
1019
+ def _get(row: tuple[Any, ...], col_idx: int | None) -> Any:
1020
+ if col_idx is None or col_idx >= len(row):
1021
+ return None
1022
+ return row[col_idx]
1023
+
1024
+ c_time = _col("time")
1025
+ c_in_use = _col("in_use")
1026
+ c_jump = _col("time_jump")
1027
+ c_in_use_inv = _col("in_use_invest")
1028
+ c_jump_inv = _col("time_jump_invest")
1029
+
1030
+ for row in rows[1:]:
1031
+ if _row_is_empty(row):
1032
+ continue
1033
+ time_id = _safe_str(_get(row, c_time))
1034
+ if time_id is None:
1035
+ continue
1036
+
1037
+ in_use_val = _safe_float(_get(row, c_in_use))
1038
+ in_use_inv_val = _safe_float(_get(row, c_in_use_inv))
1039
+ jump_val = _safe_float(_get(row, c_jump))
1040
+ jump_inv_val = _safe_float(_get(row, c_jump_inv))
1041
+
1042
+ result.append(TimeStep(
1043
+ time_id=time_id,
1044
+ in_use=bool(in_use_val) if in_use_val is not None else False,
1045
+ time_jump=jump_val if jump_val is not None else 0.0,
1046
+ in_use_invest=bool(in_use_inv_val) if in_use_inv_val is not None else False,
1047
+ time_jump_invest=jump_inv_val if jump_inv_val is not None else 0.0,
1048
+ ))
1049
+ return result
1050
+
1051
+
1052
+ # ---------------------------------------------------------------------------
1053
+ # Main entry point
1054
+ # ---------------------------------------------------------------------------
1055
+
1056
+ def read_old_flextool(file_path: str) -> OldFlexToolData:
1057
+ """Read an old-format FlexTool Excel file and return structured data.
1058
+
1059
+ Args:
1060
+ file_path: Path to the .xlsm file.
1061
+
1062
+ Returns:
1063
+ An OldFlexToolData instance with all parsed sheets.
1064
+ """
1065
+ logger.info("Reading old FlexTool file: %s", file_path)
1066
+ wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
1067
+
1068
+ try:
1069
+ # Log which sheets are present and which will be skipped
1070
+ for name in wb.sheetnames:
1071
+ if name in _SKIP_SHEETS:
1072
+ logger.debug("Skipping sheet: %s", name)
1073
+
1074
+ master = _read_master(wb)
1075
+ grid_nodes = _read_grid_nodes(wb)
1076
+ node_groups = _read_node_groups(wb)
1077
+ unit_types = _read_unit_types(wb)
1078
+ fuels = _read_fuels(wb)
1079
+ unit_groups = _read_unit_groups(wb)
1080
+ units = _read_units(wb)
1081
+ connections = _read_connections(wb)
1082
+ cf_profiles = _read_profile_ts(wb, "ts_cf")
1083
+ inflow_profiles = _read_profile_ts(wb, "ts_inflow")
1084
+ demand_ts = _read_demand_ts(wb, "ts_energy")
1085
+ import_ts = _read_demand_ts(wb, "ts_import")
1086
+ reserve_node_ts = _read_reserve_ts(wb, "ts_reserve_node")
1087
+ reserve_group_ts = _read_reserve_ts(wb, "ts_reserve_nodeGroup")
1088
+ unit_ts = _read_unit_ts(wb)
1089
+ time_steps = _read_time_steps(wb)
1090
+
1091
+ logger.info(
1092
+ "Parsed: %d nodes, %d unit types, %d units, %d connections, "
1093
+ "%d cf profiles, %d time steps",
1094
+ len(grid_nodes), len(unit_types), len(units), len(connections),
1095
+ len(cf_profiles), len(time_steps),
1096
+ )
1097
+
1098
+ return OldFlexToolData(
1099
+ master=master,
1100
+ grid_nodes=grid_nodes,
1101
+ node_groups=node_groups,
1102
+ unit_types=unit_types,
1103
+ fuels=fuels,
1104
+ unit_groups=unit_groups,
1105
+ units=units,
1106
+ connections=connections,
1107
+ cf_profiles=cf_profiles,
1108
+ inflow_profiles=inflow_profiles,
1109
+ demand_ts=demand_ts,
1110
+ import_ts=import_ts,
1111
+ reserve_node_ts=reserve_node_ts,
1112
+ reserve_group_ts=reserve_group_ts,
1113
+ unit_ts=unit_ts,
1114
+ time_steps=time_steps,
1115
+ )
1116
+ finally:
1117
+ wb.close()
1118
+
1119
+
1120
+ # ---------------------------------------------------------------------------
1121
+ # Entity identifier columns by section type
1122
+ # ---------------------------------------------------------------------------
1123
+
1124
+ _SENSITIVITY_ENTITY_ID_COLS: dict[str, set[str]] = {
1125
+ "master": set(),
1126
+ "nodeGroup": {"nodegroup"},
1127
+ "gridNode": {"grid", "node", "nodegroup", "nodegroup2", "nodegroup3"},
1128
+ "unit_type": {"unit type"},
1129
+ "fuel": {"fuel"},
1130
+ "unitGroup": {"unitgroup"},
1131
+ "units": {
1132
+ "unitgroup", "unittype", "fuel", "cf profile", "inflow",
1133
+ "input grid", "input node", "output grid", "output node",
1134
+ },
1135
+ "nodeNode": {"grid", "node1", "node2"},
1136
+ }
1137
+
1138
+ # String-typed entity ID columns (stay as strings, never float-convert)
1139
+ _SENSITIVITY_STRING_COLS: set[str] = {
1140
+ "nodegroup", "nodegroup2", "nodegroup3",
1141
+ "grid", "node", "node1", "node2",
1142
+ "unit type", "unittype", "unitgroup",
1143
+ "fuel", "cf profile", "inflow",
1144
+ "input grid", "input node", "output grid", "output node",
1145
+ }
1146
+
1147
+
1148
+ def read_old_flextool_sensitivities(
1149
+ file_path: str,
1150
+ ) -> dict[str, list[SensitivityOverride]]:
1151
+ """Read the 'Sensitivity definitions' sheet and return per-scenario overrides.
1152
+
1153
+ The sheet contains multiple sections separated by empty rows. Each section
1154
+ starts with a header row like "Scenario definitions master:" followed by
1155
+ column headers and data rows.
1156
+
1157
+ Args:
1158
+ file_path: Path to the old FlexTool .xlsm file.
1159
+
1160
+ Returns:
1161
+ A dict mapping scenario_name -> list of SensitivityOverride objects.
1162
+ """
1163
+ logger.info("Reading sensitivities from: %s", file_path)
1164
+ wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
1165
+
1166
+ try:
1167
+ ws = _get_sheet(wb, "Sensitivity definitions")
1168
+ if ws is None:
1169
+ logger.warning("No 'Sensitivity definitions' sheet found.")
1170
+ return {}
1171
+
1172
+ rows = list(ws.iter_rows(values_only=True))
1173
+ if not rows:
1174
+ return {}
1175
+
1176
+ result: dict[str, list[SensitivityOverride]] = {}
1177
+ _parse_sensitivity_sections(rows, result)
1178
+
1179
+ total = sum(len(v) for v in result.values())
1180
+ logger.info(
1181
+ "Parsed %d sensitivity scenarios with %d total overrides.",
1182
+ len(result), total,
1183
+ )
1184
+ return result
1185
+
1186
+ finally:
1187
+ wb.close()
1188
+
1189
+
1190
+ def _parse_sensitivity_sections(
1191
+ rows: list[tuple[Any, ...]],
1192
+ result: dict[str, list[SensitivityOverride]],
1193
+ ) -> None:
1194
+ """Parse all sections from the Sensitivity definitions sheet rows."""
1195
+ i = 0
1196
+ while i < len(rows):
1197
+ row = rows[i]
1198
+ # Detect section header: col 0 starts with "Scenario definitions"
1199
+ cell0 = _safe_str(row[0]) if len(row) > 0 else None
1200
+ if cell0 is not None and cell0.lower().startswith("scenario definitions"):
1201
+ # This row is the header row with column names in col 2+
1202
+ headers = [_safe_str(c) for c in row]
1203
+ # Extract the section type from the header text after ":"
1204
+ # e.g. "Scenario definitions master:" -> determine from data rows
1205
+ i += 1
1206
+ # Parse data rows until next empty row or next section header
1207
+ i = _parse_sensitivity_data_rows(rows, i, headers, result)
1208
+ else:
1209
+ i += 1
1210
+
1211
+
1212
+ def _parse_sensitivity_data_rows(
1213
+ rows: list[tuple[Any, ...]],
1214
+ start: int,
1215
+ headers: list[str | None],
1216
+ result: dict[str, list[SensitivityOverride]],
1217
+ ) -> int:
1218
+ """Parse data rows for one section. Returns the index after the section."""
1219
+ i = start
1220
+ while i < len(rows):
1221
+ row = rows[i]
1222
+ if _row_is_empty(row):
1223
+ # Empty row signals end of section
1224
+ return i + 1
1225
+
1226
+ # Check if this is the start of a new section header
1227
+ cell0 = _safe_str(row[0]) if len(row) > 0 else None
1228
+ if cell0 is not None and cell0.lower().startswith("scenario definitions"):
1229
+ return i # Don't advance — let the caller re-process this row
1230
+
1231
+ # Col 0: scenario name, Col 1: sheet/section type
1232
+ scenario_name = cell0
1233
+ section_type = _safe_str(row[1]) if len(row) > 1 else None
1234
+
1235
+ # Skip rows where col 0 is empty (placeholder rows with just sheet name)
1236
+ if scenario_name is None or section_type is None:
1237
+ i += 1
1238
+ continue
1239
+
1240
+ # Determine entity ID vs parameter columns based on section type
1241
+ entity_id_col_names = _SENSITIVITY_ENTITY_ID_COLS.get(section_type, set())
1242
+
1243
+ # Build entity_ids from the ID columns
1244
+ entity_ids: dict[str, str] = {}
1245
+ for col_idx in range(2, min(len(headers), len(row))):
1246
+ header = headers[col_idx]
1247
+ if header is None:
1248
+ continue
1249
+ header_lower = header.strip().lower()
1250
+ if header_lower in entity_id_col_names:
1251
+ val = _safe_str(row[col_idx])
1252
+ if val is not None:
1253
+ entity_ids[header.strip()] = val
1254
+
1255
+ # Create overrides for each non-empty parameter cell
1256
+ for col_idx in range(2, min(len(headers), len(row))):
1257
+ header = headers[col_idx]
1258
+ if header is None:
1259
+ continue
1260
+ header_lower = header.strip().lower()
1261
+ if header_lower in entity_id_col_names:
1262
+ continue # Skip entity ID columns
1263
+
1264
+ raw_value = row[col_idx]
1265
+ if raw_value is None:
1266
+ continue
1267
+
1268
+ # Try float first, fall back to string
1269
+ float_val = _safe_float(raw_value)
1270
+ if float_val is not None:
1271
+ value: float | str = float_val
1272
+ else:
1273
+ str_val = _safe_str(raw_value)
1274
+ if str_val is None:
1275
+ continue
1276
+ value = str_val
1277
+
1278
+ override = SensitivityOverride(
1279
+ section=section_type,
1280
+ entity_ids=dict(entity_ids), # copy
1281
+ param_name=header.strip(),
1282
+ value=value,
1283
+ )
1284
+ result.setdefault(scenario_name, []).append(override)
1285
+
1286
+ i += 1
1287
+
1288
+ return i