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,3077 @@
1
+ """Write parsed old FlexTool data (from read_old_flextool) to a Spine database.
2
+
3
+ This module takes an OldFlexToolData instance and writes all entities, parameters,
4
+ relationships, and structural items (timeline, solve, model) into a SpineDB that
5
+ already has the FlexTool entity classes and parameter definitions (from the template).
6
+
7
+ Usage::
8
+
9
+ from flextool.process_inputs.read_old_flextool import read_old_flextool
10
+ from flextool.process_inputs.write_old_flextool_to_db import write_old_flextool_to_db
11
+
12
+ data = read_old_flextool("old_model.xlsm")
13
+ write_old_flextool_to_db(data, "sqlite:///flextool_input.sqlite")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any
20
+
21
+ import spinedb_api as api
22
+ from spinedb_api import Array, DatabaseMapping, Map, SpineDBAPIError
23
+ from spinedb_api.exception import NothingToCommit
24
+
25
+ from flextool.process_inputs.read_old_flextool import (
26
+ DemandTimeSeries,
27
+ OldFlexToolData,
28
+ SensitivityOverride,
29
+ TimeSeriesData,
30
+ UnitInstance,
31
+ UnitTimeSeries,
32
+ UnitType,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Counters for summary logging
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class _Counters:
43
+ """Simple counters for tracking what was written."""
44
+
45
+ def __init__(self) -> None:
46
+ self.entities: int = 0
47
+ self.relationships: int = 0
48
+ self.parameters: int = 0
49
+ self.entity_alternatives: int = 0
50
+
51
+ def summary(self) -> str:
52
+ return (
53
+ f"entities={self.entities}, relationships={self.relationships}, "
54
+ f"parameters={self.parameters}, entity_alternatives={self.entity_alternatives}"
55
+ )
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Helper functions
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def _add_entity(
64
+ db: DatabaseMapping,
65
+ class_name: str,
66
+ name: str,
67
+ alt_name: str,
68
+ counters: _Counters,
69
+ entities_added: set[tuple[str, tuple[str, ...]]],
70
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
71
+ ) -> None:
72
+ """Add a 0-dimensional entity and its entity_alternative."""
73
+ key = (class_name, (name,))
74
+ if key not in entities_added:
75
+ entities_added.add(key)
76
+ try:
77
+ db.add_entity(entity_class_name=class_name, name=name)
78
+ counters.entities += 1
79
+ except SpineDBAPIError as exc:
80
+ logger.debug("Entity %s '%s' may already exist: %s", class_name, name, exc)
81
+
82
+ ea_key = (class_name, (name,), alt_name)
83
+ if ea_key not in entity_alts_added:
84
+ entity_alts_added.add(ea_key)
85
+ try:
86
+ db.add_entity_alternative(
87
+ entity_class_name=class_name,
88
+ entity_byname=(name,),
89
+ alternative_name=alt_name,
90
+ active=True,
91
+ )
92
+ counters.entity_alternatives += 1
93
+ except SpineDBAPIError as exc:
94
+ logger.debug(
95
+ "Entity alternative %s '%s' alt='%s' may already exist: %s",
96
+ class_name, name, alt_name, exc,
97
+ )
98
+
99
+
100
+ def _add_relationship(
101
+ db: DatabaseMapping,
102
+ class_name: str,
103
+ elements: tuple[str, ...],
104
+ alt_name: str,
105
+ counters: _Counters,
106
+ entities_added: set[tuple[str, tuple[str, ...]]],
107
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
108
+ ) -> None:
109
+ """Add a multi-dimensional entity (relationship) and its entity_alternative."""
110
+ key = (class_name, elements)
111
+ if key not in entities_added:
112
+ entities_added.add(key)
113
+ try:
114
+ db.add_entity(entity_class_name=class_name, entity_byname=elements)
115
+ counters.relationships += 1
116
+ except SpineDBAPIError as exc:
117
+ logger.debug(
118
+ "Relationship %s %s may already exist: %s", class_name, elements, exc,
119
+ )
120
+
121
+ ea_key = (class_name, elements, alt_name)
122
+ if ea_key not in entity_alts_added:
123
+ entity_alts_added.add(ea_key)
124
+ try:
125
+ db.add_entity_alternative(
126
+ entity_class_name=class_name,
127
+ entity_byname=elements,
128
+ alternative_name=alt_name,
129
+ active=True,
130
+ )
131
+ counters.entity_alternatives += 1
132
+ except SpineDBAPIError as exc:
133
+ logger.debug(
134
+ "Relationship entity_alt %s %s alt='%s' may already exist: %s",
135
+ class_name, elements, alt_name, exc,
136
+ )
137
+
138
+
139
+ def _add_param(
140
+ db: DatabaseMapping,
141
+ class_name: str,
142
+ entity_byname: tuple[str, ...],
143
+ param_name: str,
144
+ value: Any,
145
+ alt_name: str,
146
+ counters: _Counters,
147
+ ) -> None:
148
+ """Add a parameter value, converting via api.to_database()."""
149
+ if value is None:
150
+ return
151
+ db_value, db_type = api.to_database(value)
152
+ try:
153
+ db.add_parameter_value(
154
+ entity_class_name=class_name,
155
+ entity_byname=entity_byname,
156
+ parameter_definition_name=param_name,
157
+ alternative_name=alt_name,
158
+ value=db_value,
159
+ type=db_type,
160
+ )
161
+ counters.parameters += 1
162
+ except SpineDBAPIError as exc:
163
+ logger.warning(
164
+ "Could not add param %s.%s %s: %s",
165
+ class_name, param_name, entity_byname, exc,
166
+ )
167
+
168
+
169
+ def _add_param_if_set(
170
+ db: DatabaseMapping,
171
+ class_name: str,
172
+ entity_byname: tuple[str, ...],
173
+ param_name: str,
174
+ value: Any,
175
+ alt_name: str,
176
+ counters: _Counters,
177
+ skip_zero: bool = True,
178
+ ) -> None:
179
+ """Add a parameter value only if it is not None and (optionally) not zero."""
180
+ if value is None:
181
+ return
182
+ if skip_zero and isinstance(value, (int, float)) and value == 0:
183
+ return
184
+ _add_param(db, class_name, entity_byname, param_name, value, alt_name, counters)
185
+
186
+
187
+ def _make_time_map(
188
+ ts_data: dict[str, float],
189
+ index_name: str = "time",
190
+ ) -> Map:
191
+ """Convert a {time_id: value} dict to a spinedb_api.Map."""
192
+ indexes = list(ts_data.keys())
193
+ values = list(ts_data.values())
194
+ return Map(indexes, values, index_name=index_name)
195
+
196
+
197
+ def _get_unit_type(data: OldFlexToolData, unit: UnitInstance) -> UnitType | None:
198
+ """Look up the UnitType matching a unit's unit_type field."""
199
+ for ut in data.unit_types:
200
+ if ut.name == unit.unit_type:
201
+ return ut
202
+ return None
203
+
204
+
205
+ def _get_unit_type_param(
206
+ data: OldFlexToolData,
207
+ unit: UnitInstance,
208
+ param_key: str,
209
+ ) -> float | None:
210
+ """Look up a parameter value from the unit's unit_type template."""
211
+ ut = _get_unit_type(data, unit)
212
+ if ut is None:
213
+ return None
214
+ return ut.params.get(param_key)
215
+
216
+
217
+ def _get_unit_name(unit: UnitInstance) -> str:
218
+ """Generate a unique unit name: {unit_type}_{output_node}."""
219
+ return f"{unit.unit_type}_{unit.output_node}"
220
+
221
+
222
+ def _unit_has_storage(unit: UnitInstance) -> bool:
223
+ """Return True if the unit has existing or investable storage capacity."""
224
+ return (
225
+ (unit.storage_mwh is not None and unit.storage_mwh != 0)
226
+ or (unit.invested_storage_mwh is not None and unit.invested_storage_mwh > 0)
227
+ or (unit.max_invest_mwh is not None and unit.max_invest_mwh > 0)
228
+ )
229
+
230
+
231
+ def _get_unit_node_name(unit: UnitInstance) -> str:
232
+ """Generate the input node name for a non-charger/discharger unit.
233
+
234
+ Uses '_storage' if the unit has storage capacity or investment,
235
+ '_node' otherwise (pure inflow / run-of-river).
236
+ """
237
+ suffix = "_storage" if _unit_has_storage(unit) else "_node"
238
+ return f"{_get_unit_name(unit)}{suffix}"
239
+
240
+
241
+ def _get_master_param(data: OldFlexToolData, key: str, default: float = 0.0) -> float:
242
+ """Get a master parameter with a default fallback.
243
+
244
+ Tries the key as-is first, then with underscores replaced by spaces
245
+ and vice versa, to handle naming variations between files.
246
+ """
247
+ if key in data.master.params:
248
+ return data.master.params[key]
249
+ alt_key = key.replace(" ", "_")
250
+ if alt_key in data.master.params:
251
+ return data.master.params[alt_key]
252
+ alt_key = key.replace("_", " ")
253
+ if alt_key in data.master.params:
254
+ return data.master.params[alt_key]
255
+ return default
256
+
257
+
258
+ def _find_demand_ts(
259
+ data: OldFlexToolData, grid: str, node: str,
260
+ ) -> DemandTimeSeries | None:
261
+ """Find the demand time series for a given grid/node pair."""
262
+ for dts in data.demand_ts:
263
+ if dts.grid == grid and dts.node == node:
264
+ return dts
265
+ return None
266
+
267
+
268
+ def _find_inflow_profile(
269
+ data: OldFlexToolData, profile_name: str,
270
+ ) -> TimeSeriesData | None:
271
+ """Find an inflow profile by name."""
272
+ for ip in data.inflow_profiles:
273
+ if ip.name == profile_name:
274
+ return ip
275
+ return None
276
+
277
+
278
+ def _find_cf_profile(
279
+ data: OldFlexToolData, profile_name: str,
280
+ ) -> TimeSeriesData | None:
281
+ """Find a capacity factor profile by name."""
282
+ for cp in data.cf_profiles:
283
+ if cp.name == profile_name:
284
+ return cp
285
+ return None
286
+
287
+
288
+ def _find_unit_ts(
289
+ data: OldFlexToolData,
290
+ unit: UnitInstance,
291
+ param_name: str,
292
+ ) -> UnitTimeSeries | None:
293
+ """Find a unit time series by grid/node/unit/param match."""
294
+ for uts in data.unit_ts:
295
+ if (
296
+ uts.grid == unit.output_grid
297
+ and uts.node == unit.output_node
298
+ and uts.unit == unit.unit_type
299
+ and uts.param_name == param_name
300
+ ):
301
+ return uts
302
+ return None
303
+
304
+
305
+ def _fuel_market_name(fuel_name: str) -> str:
306
+ """Return the market node name for a fuel."""
307
+ return f"{fuel_name}_market"
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Section writers
312
+ # ---------------------------------------------------------------------------
313
+
314
+
315
+ def _write_commodities(
316
+ data: OldFlexToolData,
317
+ db: DatabaseMapping,
318
+ alt_name: str,
319
+ counters: _Counters,
320
+ entities_added: set[tuple[str, tuple[str, ...]]],
321
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
322
+ ) -> None:
323
+ """Write commodity entities and fuel market nodes (Section 1)."""
324
+ # Determine which fuels are actually used by units
325
+ used_fuels: set[str] = set()
326
+ for unit in data.units:
327
+ if unit.fuel:
328
+ used_fuels.add(unit.fuel)
329
+
330
+ for fuel in data.fuels:
331
+ if fuel.name not in used_fuels:
332
+ continue
333
+
334
+ # Create commodity entity
335
+ _add_entity(db, "commodity", fuel.name, alt_name, counters,
336
+ entities_added, entity_alts_added)
337
+
338
+ # Create fuel market node. It is a commodity source, so node_type
339
+ # MUST be set to "commodity" — an unset node_type defaults to
340
+ # "balance" in the engine (_emit_mid_sets.py "default 'balance'"),
341
+ # which would give the market no supply and starve the fuel.
342
+ market_name = _fuel_market_name(fuel.name)
343
+ _add_entity(db, "node", market_name, alt_name, counters,
344
+ entities_added, entity_alts_added)
345
+ _add_param(db, "node", (market_name,), "node_type", "commodity",
346
+ alt_name, counters)
347
+
348
+ # Create commodity__node relationship
349
+ _add_relationship(db, "commodity__node", (fuel.name, market_name),
350
+ alt_name, counters, entities_added, entity_alts_added)
351
+
352
+ # Set commodity parameters
353
+ _add_param(db, "commodity", (fuel.name,), "price", fuel.price_per_mwh,
354
+ alt_name, counters)
355
+ _add_param_if_set(db, "commodity", (fuel.name,), "co2_content",
356
+ fuel.co2_content, alt_name, counters)
357
+
358
+ logger.info("Wrote %d commodity/fuel-market entities.", len(used_fuels))
359
+
360
+
361
+ def _write_balance_nodes(
362
+ data: OldFlexToolData,
363
+ db: DatabaseMapping,
364
+ alt_name: str,
365
+ counters: _Counters,
366
+ entities_added: set[tuple[str, tuple[str, ...]]],
367
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
368
+ ) -> None:
369
+ """Write balance nodes from grid_nodes (Section 2)."""
370
+ lolp = _get_master_param(data, "loss of load penalty")
371
+
372
+ for gn in data.grid_nodes:
373
+ _add_entity(db, "node", gn.node, alt_name, counters,
374
+ entities_added, entity_alts_added)
375
+ _add_param(db, "node", (gn.node,), "node_type", "balance",
376
+ alt_name, counters)
377
+
378
+ if lolp > 0:
379
+ _add_param(db, "node", (gn.node,), "penalty_up", lolp,
380
+ alt_name, counters)
381
+ _add_param(db, "node", (gn.node,), "penalty_down", lolp,
382
+ alt_name, counters)
383
+
384
+ # Demand as negative inflow
385
+ demand_ts = _find_demand_ts(data, gn.grid, gn.node)
386
+ if demand_ts and demand_ts.data:
387
+ neg_data = {k: -v for k, v in demand_ts.data.items()}
388
+ inflow_map = _make_time_map(neg_data)
389
+ _add_param(db, "node", (gn.node,), "inflow", inflow_map,
390
+ alt_name, counters)
391
+
392
+ if gn.demand_mwh is not None and gn.demand_mwh != 0:
393
+ _add_param(db, "node", (gn.node,), "inflow_method",
394
+ "scale_to_annual_flow", alt_name, counters)
395
+ _add_param(db, "node", (gn.node,), "annual_flow",
396
+ gn.demand_mwh, alt_name, counters)
397
+
398
+ logger.info("Wrote %d balance nodes.", len(data.grid_nodes))
399
+
400
+
401
+ def _has_eff_charge(unit: UnitInstance, data: OldFlexToolData) -> bool:
402
+ """Return True if the unit's unit_type has an eff_charge parameter."""
403
+ ut = _get_unit_type(data, unit)
404
+ if ut is None:
405
+ return False
406
+ ec = ut.params.get("eff charge")
407
+ return ec is not None and ec > 0
408
+
409
+
410
+ def _needs_charger_discharger(unit: UnitInstance, data: OldFlexToolData) -> bool:
411
+ """Return True if the unit should be split into charger + discharger.
412
+
413
+ Based on the existence of ``eff_charge`` in the unit_type, which indicates
414
+ the unit has a charging/storage dimension (battery, pumped hydro, etc.).
415
+ """
416
+ return _has_eff_charge(unit, data)
417
+
418
+
419
+ def _write_storage_units(
420
+ data: OldFlexToolData,
421
+ db: DatabaseMapping,
422
+ alt_name: str,
423
+ counters: _Counters,
424
+ entities_added: set[tuple[str, tuple[str, ...]]],
425
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
426
+ ) -> None:
427
+ """Write storage nodes with charger/discharger units (Section 3).
428
+
429
+ For pure storage units (no fuel, no cf_profile, no inflow), creates:
430
+ - A storage node with node_type='storage'
431
+ - A charger unit: output_node → storage_node
432
+ - A discharger unit: storage_node → output_node
433
+ - Investment constraint linking charger and discharger capacities
434
+ - Optional kW/kWh constraint linking discharger and storage node capacities
435
+ """
436
+ use_online = _get_master_param(data, "use online") == 1
437
+ use_ramps = _get_master_param(data, "use ramps") == 1
438
+ lolp = _get_master_param(data, "loss of load penalty")
439
+ count = 0
440
+
441
+ for unit in data.units:
442
+ if not _needs_charger_discharger(unit, data):
443
+ continue
444
+
445
+ unit_name = _get_unit_name(unit)
446
+ _get_unit_type(data, unit)
447
+ storage_node = f"{unit_name}_storage"
448
+ charger_name = f"{unit_name}_charger"
449
+ discharger_name = f"{unit_name}_discharger"
450
+ output_node = unit.output_node
451
+
452
+ if output_node is None:
453
+ logger.warning("Unit '%s' has no output node; skipping storage.", unit_name)
454
+ continue
455
+
456
+ capacity = unit.capacity_mw or 0.0
457
+
458
+ # ── Storage node ──────────────────────────────────────────
459
+ _add_entity(db, "node", storage_node, alt_name, counters,
460
+ entities_added, entity_alts_added)
461
+ _add_param(db, "node", (storage_node,), "node_type", "storage",
462
+ alt_name, counters)
463
+ _add_param_if_set(db, "node", (storage_node,), "existing",
464
+ unit.storage_mwh, alt_name, counters, skip_zero=False)
465
+ _add_param(db, "node", (storage_node,), "storage_binding_method",
466
+ "bind_within_solve", alt_name, counters)
467
+
468
+ if lolp > 0:
469
+ _add_param(db, "node", (storage_node,), "penalty_up", lolp,
470
+ alt_name, counters)
471
+ _add_param(db, "node", (storage_node,), "penalty_down", 0.0,
472
+ alt_name, counters)
473
+
474
+ self_discharge = _get_unit_type_param(data, unit, "self discharge loss")
475
+ _add_param_if_set(db, "node", (storage_node,), "self_discharge_loss",
476
+ self_discharge, alt_name, counters)
477
+ _add_param_if_set(db, "node", (storage_node,), "storage_state_start",
478
+ unit.storage_start, alt_name, counters, skip_zero=False)
479
+
480
+ # Storage node investment (inv.cost/kWh)
481
+ inv_cost_kwh = _get_unit_type_param(data, unit, "inv.cost/kwh")
482
+ _add_param_if_set(db, "node", (storage_node,), "invest_cost",
483
+ inv_cost_kwh, alt_name, counters)
484
+ if unit.max_invest_mwh is not None and unit.max_invest_mwh > 0:
485
+ _add_param(db, "node", (storage_node,), "invest_max_total",
486
+ unit.max_invest_mwh, alt_name, counters)
487
+ _add_param(db, "node", (storage_node,), "invest_method",
488
+ "invest_total", alt_name, counters)
489
+
490
+ # Inflow on storage node (pumped hydro pattern)
491
+ if unit.inflow_profile:
492
+ inflow_ts = _find_inflow_profile(data, unit.inflow_profile)
493
+ if inflow_ts and inflow_ts.data:
494
+ ts_data = inflow_ts.data
495
+ if unit.inflow_multiplier is not None and unit.inflow_multiplier != 0:
496
+ ts_data = {k: v * unit.inflow_multiplier for k, v in ts_data.items()}
497
+ inflow_map = _make_time_map(ts_data)
498
+ _add_param(db, "node", (storage_node,), "inflow",
499
+ inflow_map, alt_name, counters)
500
+ _add_param(db, "node", (storage_node,), "inflow_method",
501
+ "use_original", alt_name, counters)
502
+
503
+ # ── Charger unit ──────────────────────────────────────────
504
+ _add_entity(db, "unit", charger_name, alt_name, counters,
505
+ entities_added, entity_alts_added)
506
+ _add_param(db, "unit", (charger_name,), "conversion_method",
507
+ "constant_efficiency", alt_name, counters)
508
+ _add_param(db, "unit", (charger_name,), "existing", capacity,
509
+ alt_name, counters)
510
+
511
+ eff_charge = _get_unit_type_param(data, unit, "eff charge")
512
+ _add_param(db, "unit", (charger_name,), "efficiency",
513
+ eff_charge if eff_charge is not None else 1.0, alt_name, counters)
514
+
515
+ avail = _get_unit_type_param(data, unit, "availability")
516
+ if avail is not None and avail < 1.0:
517
+ _add_param(db, "unit", (charger_name,), "availability", avail,
518
+ alt_name, counters)
519
+
520
+ # Charger relationships
521
+ _add_relationship(db, "unit__inputNode", (charger_name, output_node),
522
+ alt_name, counters, entities_added, entity_alts_added)
523
+ _add_relationship(db, "unit__outputNode", (charger_name, storage_node),
524
+ alt_name, counters, entities_added, entity_alts_added)
525
+
526
+ # Charger ramps
527
+ if use_ramps:
528
+ ramp_up = _get_unit_type_param(data, unit, "ramp up (p.u. per min)")
529
+ ramp_down = _get_unit_type_param(data, unit, "ramp down (p.u. per min)")
530
+ if ramp_up is not None and ramp_up > 0:
531
+ _add_param(db, "unit__outputNode",
532
+ (charger_name, storage_node),
533
+ "ramp_method", "ramp_limit", alt_name, counters)
534
+ _add_param(db, "unit__outputNode",
535
+ (charger_name, storage_node),
536
+ "ramp_speed_up", ramp_up, alt_name, counters)
537
+ if ramp_down is not None and ramp_down > 0:
538
+ _add_param(db, "unit__outputNode",
539
+ (charger_name, storage_node),
540
+ "ramp_speed_down", ramp_down, alt_name, counters)
541
+
542
+ # Charger non-synchronous / inertia on inputNode (the grid side)
543
+ non_sync = _get_unit_type_param(data, unit, "non synchronous")
544
+ if non_sync is not None and non_sync == 1:
545
+ _add_param(db, "unit__inputNode",
546
+ (charger_name, output_node),
547
+ "is_non_synchronous", "yes", alt_name, counters)
548
+ inertia_const = _get_unit_type_param(data, unit, "inertia constant (mws/mw)")
549
+ _add_param_if_set(db, "unit__inputNode",
550
+ (charger_name, output_node),
551
+ "inertia_constant", inertia_const, alt_name, counters)
552
+
553
+ # ── Discharger unit ───────────────────────────────────────
554
+ _add_entity(db, "unit", discharger_name, alt_name, counters,
555
+ entities_added, entity_alts_added)
556
+ _add_param(db, "unit", (discharger_name,), "existing", capacity,
557
+ alt_name, counters)
558
+
559
+ # Discharger conversion method — use min_load_efficiency when applicable
560
+ eff_val = _get_unit_type_param(data, unit, "efficiency")
561
+ eff_at_min = _get_unit_type_param(data, unit, "eff at min load")
562
+ min_load_val = _get_unit_type_param(data, unit, "min load")
563
+ has_min_load = (
564
+ min_load_val is not None and min_load_val > 0
565
+ and eff_at_min is not None
566
+ and eff_val is not None
567
+ and eff_at_min != eff_val
568
+ )
569
+ if has_min_load:
570
+ _add_param(db, "unit", (discharger_name,), "conversion_method",
571
+ "min_load_efficiency", alt_name, counters)
572
+ _add_param(db, "unit", (discharger_name,), "startup_method",
573
+ "linear", alt_name, counters)
574
+ _add_param(db, "unit", (discharger_name,), "min_load",
575
+ min_load_val, alt_name, counters)
576
+ _add_param(db, "unit", (discharger_name,), "efficiency_at_min_load",
577
+ eff_at_min, alt_name, counters)
578
+ else:
579
+ _add_param(db, "unit", (discharger_name,), "conversion_method",
580
+ "constant_efficiency", alt_name, counters)
581
+
582
+ _add_param(db, "unit", (discharger_name,), "efficiency",
583
+ eff_val if eff_val is not None else 1.0, alt_name, counters)
584
+
585
+ if avail is not None and avail < 1.0:
586
+ _add_param(db, "unit", (discharger_name,), "availability", avail,
587
+ alt_name, counters)
588
+
589
+ # Discharger startup (only on discharger)
590
+ startup_cost = _get_unit_type_param(data, unit, "startup cost")
591
+ if use_online and startup_cost is not None and startup_cost > 0:
592
+ _add_param(db, "unit", (discharger_name,), "startup_cost",
593
+ startup_cost, alt_name, counters)
594
+ _add_param(db, "unit", (discharger_name,), "startup_method",
595
+ "linear", alt_name, counters)
596
+
597
+ min_uptime = _get_unit_type_param(data, unit, "min uptime (h)")
598
+ _add_param_if_set(db, "unit", (discharger_name,), "min_uptime",
599
+ min_uptime, alt_name, counters)
600
+ min_downtime = _get_unit_type_param(data, unit, "min downtime (h)")
601
+ _add_param_if_set(db, "unit", (discharger_name,), "min_downtime",
602
+ min_downtime, alt_name, counters)
603
+
604
+ # Discharger relationships
605
+ _add_relationship(db, "unit__inputNode", (discharger_name, storage_node),
606
+ alt_name, counters, entities_added, entity_alts_added)
607
+ _add_relationship(db, "unit__outputNode", (discharger_name, output_node),
608
+ alt_name, counters, entities_added, entity_alts_added)
609
+
610
+ # Discharger O&M cost (only on discharger output)
611
+ om_cost = _get_unit_type_param(data, unit, "o&m cost/mwh")
612
+ _add_param_if_set(db, "unit__outputNode",
613
+ (discharger_name, output_node),
614
+ "other_operational_cost", om_cost, alt_name, counters)
615
+
616
+ # Discharger non-synchronous / inertia on outputNode (the grid side)
617
+ if non_sync is not None and non_sync == 1:
618
+ _add_param(db, "unit__outputNode",
619
+ (discharger_name, output_node),
620
+ "is_non_synchronous", "yes", alt_name, counters)
621
+ _add_param_if_set(db, "unit__outputNode",
622
+ (discharger_name, output_node),
623
+ "inertia_constant", inertia_const, alt_name, counters)
624
+
625
+ # Discharger ramps
626
+ if use_ramps:
627
+ ramp_up = _get_unit_type_param(data, unit, "ramp up (p.u. per min)")
628
+ ramp_down = _get_unit_type_param(data, unit, "ramp down (p.u. per min)")
629
+ if ramp_up is not None and ramp_up > 0:
630
+ _add_param(db, "unit__outputNode",
631
+ (discharger_name, output_node),
632
+ "ramp_method", "ramp_limit", alt_name, counters)
633
+ _add_param(db, "unit__outputNode",
634
+ (discharger_name, output_node),
635
+ "ramp_speed_up", ramp_up, alt_name, counters)
636
+ if ramp_down is not None and ramp_down > 0:
637
+ _add_param(db, "unit__outputNode",
638
+ (discharger_name, output_node),
639
+ "ramp_speed_down", ramp_down, alt_name, counters)
640
+
641
+ # ── Investment params ──────────────────────────────────────
642
+ inv_cost_kw = _get_unit_type_param(data, unit, "inv.cost/kw")
643
+ lifetime = _get_unit_type_param(data, unit, "lifetime")
644
+ interest = _get_unit_type_param(data, unit, "interest")
645
+
646
+ # Cost on discharger only; lifetime and interest on all three
647
+ # (always set so sensitivities can enable investment later)
648
+ _add_param_if_set(db, "unit", (discharger_name,), "invest_cost",
649
+ inv_cost_kw, alt_name, counters)
650
+ _add_param_if_set(db, "unit", (discharger_name,), "lifetime",
651
+ lifetime, alt_name, counters)
652
+ _add_param_if_set(db, "unit", (discharger_name,), "discount_rate",
653
+ interest, alt_name, counters)
654
+ _add_param_if_set(db, "unit", (charger_name,), "lifetime",
655
+ lifetime, alt_name, counters)
656
+ _add_param_if_set(db, "unit", (charger_name,), "discount_rate",
657
+ interest, alt_name, counters)
658
+ _add_param_if_set(db, "node", (storage_node,), "lifetime",
659
+ lifetime, alt_name, counters)
660
+ _add_param_if_set(db, "node", (storage_node,), "discount_rate",
661
+ interest, alt_name, counters)
662
+
663
+ invested_mw = unit.invested_capacity_mw
664
+ invested_mwh = unit.invested_storage_mwh
665
+ has_forced_mw = invested_mw is not None and invested_mw > 0
666
+ has_forced_mwh = invested_mwh is not None and invested_mwh > 0
667
+ has_invest = unit.max_invest_mw is not None and unit.max_invest_mw > 0
668
+
669
+ if has_forced_mw:
670
+ # Forced investment on charger and discharger
671
+ for target in (discharger_name, charger_name):
672
+ _add_param(db, "unit", (target,), "invest_method",
673
+ "invest_total", alt_name, counters)
674
+ _add_param(db, "unit", (target,), "invest_max_total",
675
+ invested_mw, alt_name, counters)
676
+ _add_param(db, "unit", (target,), "invest_min_total",
677
+ invested_mw, alt_name, counters)
678
+ elif has_invest:
679
+ for target in (discharger_name, charger_name):
680
+ _add_param(db, "unit", (target,), "invest_method",
681
+ "invest_total", alt_name, counters)
682
+ _add_param(db, "unit", (target,), "invest_max_total",
683
+ unit.max_invest_mw, alt_name, counters)
684
+
685
+ if has_forced_mwh:
686
+ # Forced investment on storage node
687
+ _add_param(db, "node", (storage_node,), "invest_method",
688
+ "invest_total", alt_name, counters)
689
+ _add_param(db, "node", (storage_node,), "invest_max_total",
690
+ invested_mwh, alt_name, counters)
691
+ _add_param(db, "node", (storage_node,), "invest_min_total",
692
+ invested_mwh, alt_name, counters)
693
+ elif unit.max_invest_mwh is not None and unit.max_invest_mwh > 0:
694
+ _add_param(db, "node", (storage_node,), "invest_method",
695
+ "invest_total", alt_name, counters)
696
+ _add_param(db, "node", (storage_node,), "invest_max_total",
697
+ unit.max_invest_mwh, alt_name, counters)
698
+
699
+ # ── Charger ↔ Discharger investment constraint ────────────
700
+ # Always active — ties charger capacity to discharger capacity
701
+ charger_link = f"{unit_name}_charger_link"
702
+ deactivate_storage_link = has_forced_mw and has_forced_mwh
703
+
704
+ _add_entity(db, "constraint", charger_link, alt_name, counters,
705
+ entities_added, entity_alts_added)
706
+ _add_param(db, "constraint", (charger_link,), "sense", "equal",
707
+ alt_name, counters)
708
+ _add_param(db, "constraint", (charger_link,), "constant", 0.0,
709
+ alt_name, counters)
710
+
711
+ # Charger always gets charger_link coefficient
712
+ _add_param(db, "unit", (charger_name,),
713
+ "constraint_invested_capacity_coeff",
714
+ Map([charger_link], [-1.0], index_name="constraint"),
715
+ alt_name, counters)
716
+
717
+ # Build discharger coefficient Map (always includes charger_link)
718
+ discharger_coeff_indexes = [charger_link]
719
+ discharger_coeff_values = [1.0]
720
+
721
+ # ── kW/kWh constraint (discharger ↔ storage node) ────────
722
+ # Deactivated when both MW and MWh are forced (avoids rounding issues)
723
+ kw_kwh_ratio = _get_unit_type_param(data, unit, "fixed kw/kwh ratio")
724
+ if kw_kwh_ratio is not None and kw_kwh_ratio > 0:
725
+ storage_link = f"{unit_name}_storage_link"
726
+ _add_entity(db, "constraint", storage_link, alt_name, counters,
727
+ entities_added, entity_alts_added)
728
+ _add_param(db, "constraint", (storage_link,), "sense", "equal",
729
+ alt_name, counters)
730
+ _add_param(db, "constraint", (storage_link,), "constant", 0.0,
731
+ alt_name, counters)
732
+
733
+ if deactivate_storage_link:
734
+ try:
735
+ db.add_or_update_entity_alternative(
736
+ entity_class_name="constraint",
737
+ entity_byname=(storage_link,),
738
+ alternative_name=alt_name,
739
+ active=False,
740
+ )
741
+ except SpineDBAPIError:
742
+ pass
743
+ else:
744
+ # Storage node coefficient — only when constraint is active
745
+ _add_param(db, "node", (storage_node,),
746
+ "constraint_invested_capacity_coeff",
747
+ Map([storage_link], [-1.0], index_name="constraint"),
748
+ alt_name, counters)
749
+ # Add to discharger Map
750
+ discharger_coeff_indexes.append(storage_link)
751
+ discharger_coeff_values.append(kw_kwh_ratio)
752
+
753
+ # Discharger coefficient Map (charger_link always, storage_link when active)
754
+ _add_param(db, "unit", (discharger_name,),
755
+ "constraint_invested_capacity_coeff",
756
+ Map(discharger_coeff_indexes, discharger_coeff_values,
757
+ index_name="constraint"),
758
+ alt_name, counters)
759
+
760
+ count += 1
761
+
762
+ logger.info("Wrote %d storage charger/discharger pairs.", count)
763
+
764
+
765
+ def _write_units(
766
+ data: OldFlexToolData,
767
+ db: DatabaseMapping,
768
+ alt_name: str,
769
+ counters: _Counters,
770
+ entities_added: set[tuple[str, tuple[str, ...]]],
771
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
772
+ ) -> None:
773
+ """Write unit entities and their relationships (Section 4)."""
774
+ use_online = _get_master_param(data, "use online") == 1
775
+ use_ramps = _get_master_param(data, "use ramps") == 1
776
+
777
+ for unit in data.units:
778
+ if unit.output_node is None:
779
+ logger.warning("Unit type '%s' has no output node; skipping.", unit.unit_type)
780
+ continue
781
+
782
+ # Pure storage units are handled by _write_storage_units (charger/discharger)
783
+ if _needs_charger_discharger(unit, data):
784
+ continue
785
+
786
+ unit_name = _get_unit_name(unit)
787
+ ut = _get_unit_type(data, unit)
788
+
789
+ # Create unit entity
790
+ _add_entity(db, "unit", unit_name, alt_name, counters,
791
+ entities_added, entity_alts_added)
792
+
793
+ # -- Determine conversion_method --
794
+ has_cf = unit.cf_profile is not None
795
+ min_load_val = ut.params.get("min load") if ut else None
796
+ eff_val = ut.params.get("efficiency") if ut else None
797
+ eff_at_min = ut.params.get("eff at min load") if ut else None
798
+ has_min_load = (
799
+ min_load_val is not None and min_load_val > 0
800
+ and eff_at_min is not None
801
+ and eff_val is not None
802
+ and eff_at_min != eff_val
803
+ )
804
+
805
+ if has_cf:
806
+ conversion_method = "none"
807
+ elif has_min_load:
808
+ conversion_method = "min_load_efficiency"
809
+ else:
810
+ conversion_method = "constant_efficiency"
811
+
812
+ _add_param(db, "unit", (unit_name,), "conversion_method",
813
+ conversion_method, alt_name, counters)
814
+
815
+ # -- existing capacity --
816
+ existing = unit.capacity_mw if unit.capacity_mw is not None else unit.invested_capacity_mw
817
+ _add_param_if_set(db, "unit", (unit_name,), "existing", existing,
818
+ alt_name, counters)
819
+
820
+ # -- efficiency --
821
+ # Check for time-varying efficiency first
822
+ eff_ts = _find_unit_ts(data, unit, "efficiency")
823
+ if eff_ts and eff_ts.data:
824
+ eff_map = _make_time_map(eff_ts.data)
825
+ _add_param(db, "unit", (unit_name,), "efficiency", eff_map,
826
+ alt_name, counters)
827
+ else:
828
+ eff_val = _get_unit_type_param(data, unit, "efficiency")
829
+ _add_param_if_set(db, "unit", (unit_name,), "efficiency", eff_val,
830
+ alt_name, counters)
831
+
832
+ # -- min_load and efficiency_at_min_load --
833
+ if has_min_load:
834
+ _add_param(db, "unit", (unit_name,), "min_load", min_load_val,
835
+ alt_name, counters)
836
+ eff_at_min = _get_unit_type_param(data, unit, "eff at min load")
837
+ _add_param_if_set(db, "unit", (unit_name,), "efficiency_at_min_load",
838
+ eff_at_min, alt_name, counters)
839
+
840
+ # -- availability --
841
+ avail = _get_unit_type_param(data, unit, "availability")
842
+ if avail is not None and avail < 1.0:
843
+ _add_param(db, "unit", (unit_name,), "availability", avail,
844
+ alt_name, counters)
845
+
846
+ # -- startup parameters --
847
+ startup_cost = _get_unit_type_param(data, unit, "startup cost")
848
+ # min_load_efficiency always requires startup_method = "linear"
849
+ needs_startup = (
850
+ has_min_load
851
+ or (use_online and startup_cost is not None and startup_cost > 0)
852
+ )
853
+ if needs_startup:
854
+ if startup_cost is not None and startup_cost > 0:
855
+ _add_param(db, "unit", (unit_name,), "startup_cost", startup_cost,
856
+ alt_name, counters)
857
+ _add_param(db, "unit", (unit_name,), "startup_method", "linear",
858
+ alt_name, counters)
859
+
860
+ # -- min_uptime / min_downtime --
861
+ min_uptime = _get_unit_type_param(data, unit, "min uptime (h)")
862
+ _add_param_if_set(db, "unit", (unit_name,), "min_uptime", min_uptime,
863
+ alt_name, counters)
864
+ min_downtime = _get_unit_type_param(data, unit, "min downtime (h)")
865
+ _add_param_if_set(db, "unit", (unit_name,), "min_downtime", min_downtime,
866
+ alt_name, counters)
867
+
868
+ # -- fixed_cost, invest_cost, lifetime, interest_rate --
869
+ fixed_cost = _get_unit_type_param(data, unit, "fixed cost/kw/year")
870
+ _add_param_if_set(db, "unit", (unit_name,), "fixed_cost", fixed_cost,
871
+ alt_name, counters)
872
+
873
+ invest_cost = _get_unit_type_param(data, unit, "inv.cost/kw")
874
+ _add_param_if_set(db, "unit", (unit_name,), "invest_cost", invest_cost,
875
+ alt_name, counters)
876
+
877
+ lifetime = _get_unit_type_param(data, unit, "lifetime")
878
+ _add_param_if_set(db, "unit", (unit_name,), "lifetime", lifetime,
879
+ alt_name, counters)
880
+
881
+ interest_rate = _get_unit_type_param(data, unit, "interest")
882
+ _add_param_if_set(db, "unit", (unit_name,), "discount_rate", interest_rate,
883
+ alt_name, counters)
884
+
885
+ # -- investment --
886
+ has_forced_invest = (
887
+ unit.invested_capacity_mw is not None and unit.invested_capacity_mw > 0
888
+ )
889
+ has_max_invest = (
890
+ unit.max_invest_mw is not None and unit.max_invest_mw > 0
891
+ )
892
+ if has_forced_invest:
893
+ _add_param(db, "unit", (unit_name,), "invest_method", "invest_total",
894
+ alt_name, counters)
895
+ _add_param(db, "unit", (unit_name,), "invest_max_total",
896
+ unit.invested_capacity_mw, alt_name, counters)
897
+ _add_param(db, "unit", (unit_name,), "invest_min_total",
898
+ unit.invested_capacity_mw, alt_name, counters)
899
+ elif has_max_invest:
900
+ _add_param(db, "unit", (unit_name,), "invest_method", "invest_total",
901
+ alt_name, counters)
902
+ _add_param(db, "unit", (unit_name,), "invest_max_total",
903
+ unit.max_invest_mw, alt_name, counters)
904
+
905
+ # -- Unit relationships --
906
+
907
+ # a) Fuel input
908
+ if unit.fuel:
909
+ fuel_market = _fuel_market_name(unit.fuel)
910
+ _add_relationship(db, "unit__inputNode", (unit_name, fuel_market),
911
+ alt_name, counters, entities_added, entity_alts_added)
912
+
913
+ # b) Conversion input (e.g. heat pump)
914
+ if unit.input_grid and unit.input_node:
915
+ _add_relationship(db, "unit__inputNode", (unit_name, unit.input_node),
916
+ alt_name, counters, entities_added, entity_alts_added)
917
+
918
+ # c) Primary output
919
+ _add_relationship(db, "unit__outputNode", (unit_name, unit.output_node),
920
+ alt_name, counters, entities_added, entity_alts_added)
921
+
922
+ # d) Secondary output (CHP, output2)
923
+ if unit.output2_node:
924
+ _add_relationship(db, "unit__outputNode", (unit_name, unit.output2_node),
925
+ alt_name, counters, entities_added, entity_alts_added)
926
+ # Set coefficient for secondary output capacity scaling
927
+ # output2_max_capacity / existing gives the ratio of secondary to primary capacity
928
+ if unit.output2_max_capacity and existing and existing > 0:
929
+ coeff = unit.output2_max_capacity / existing
930
+ _add_param(db, "unit__outputNode",
931
+ (unit_name, unit.output2_node),
932
+ "conversion_flow_coeff", coeff, alt_name, counters)
933
+
934
+ # -- unit__outputNode parameters (on primary output) --
935
+ om_cost = _get_unit_type_param(data, unit, "o&m cost/mwh")
936
+ _add_param_if_set(db, "unit__outputNode",
937
+ (unit_name, unit.output_node),
938
+ "other_operational_cost", om_cost, alt_name, counters)
939
+
940
+ non_sync = _get_unit_type_param(data, unit, "non synchronous")
941
+ if non_sync is not None and non_sync == 1:
942
+ _add_param(db, "unit__outputNode",
943
+ (unit_name, unit.output_node),
944
+ "is_non_synchronous", "yes", alt_name, counters)
945
+
946
+ inertia_const = _get_unit_type_param(data, unit, "inertia constant (mws/mw)")
947
+ _add_param_if_set(db, "unit__outputNode",
948
+ (unit_name, unit.output_node),
949
+ "inertia_constant", inertia_const, alt_name, counters)
950
+
951
+ # Ramp parameters
952
+ if use_ramps:
953
+ ramp_up = _get_unit_type_param(data, unit, "ramp up (p.u. per min)")
954
+ ramp_down = _get_unit_type_param(data, unit, "ramp down (p.u. per min)")
955
+ if (ramp_up is not None and ramp_up > 0) or (ramp_down is not None and ramp_down > 0):
956
+ _add_param(db, "unit__outputNode",
957
+ (unit_name, unit.output_node),
958
+ "ramp_method", "ramp_limit", alt_name, counters)
959
+ _add_param_if_set(db, "unit__outputNode",
960
+ (unit_name, unit.output_node),
961
+ "ramp_speed_up", ramp_up, alt_name, counters)
962
+ _add_param_if_set(db, "unit__outputNode",
963
+ (unit_name, unit.output_node),
964
+ "ramp_speed_down", ramp_down, alt_name, counters)
965
+
966
+ logger.info("Wrote %d units.", len(data.units))
967
+
968
+
969
+ def _write_profiles(
970
+ data: OldFlexToolData,
971
+ db: DatabaseMapping,
972
+ alt_name: str,
973
+ counters: _Counters,
974
+ entities_added: set[tuple[str, tuple[str, ...]]],
975
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
976
+ ) -> None:
977
+ """Write CF profiles and link them to units (Section 5)."""
978
+ count = 0
979
+ for profile_ts in data.cf_profiles:
980
+ if not profile_ts.data:
981
+ continue
982
+
983
+ profile_name = profile_ts.name
984
+ _add_entity(db, "profile", profile_name, alt_name, counters,
985
+ entities_added, entity_alts_added)
986
+
987
+ profile_map = _make_time_map(profile_ts.data)
988
+ _add_param(db, "profile", (profile_name,), "profile", profile_map,
989
+ alt_name, counters)
990
+
991
+ # Find units that reference this profile
992
+ for unit in data.units:
993
+ if unit.cf_profile == profile_name and unit.output_node:
994
+ unit_name = _get_unit_name(unit)
995
+ _add_relationship(
996
+ db, "unit__node__profile",
997
+ (unit_name, unit.output_node, profile_name),
998
+ alt_name, counters, entities_added, entity_alts_added,
999
+ )
1000
+ _add_param(
1001
+ db, "unit__node__profile",
1002
+ (unit_name, unit.output_node, profile_name),
1003
+ "profile_method", "upper_limit", alt_name, counters,
1004
+ )
1005
+ count += 1
1006
+
1007
+ logger.info("Wrote %d CF profiles.", count)
1008
+
1009
+
1010
+ def _write_inflow_units(
1011
+ data: OldFlexToolData,
1012
+ db: DatabaseMapping,
1013
+ alt_name: str,
1014
+ counters: _Counters,
1015
+ entities_added: set[tuple[str, tuple[str, ...]]],
1016
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1017
+ ) -> None:
1018
+ """Create dedicated inflow nodes for hydro/inflow units (Section 6).
1019
+
1020
+ For each unit with an inflow profile, creates a dedicated input node:
1021
+ - Hydro_RES (has storage): node with node_type='storage' + existing
1022
+ - Hydro_ROR (no storage): node with node_type='balance' (use-it-or-lose-it)
1023
+
1024
+ The inflow time series is set on this node and the unit draws from it
1025
+ via a unit__inputNode relationship.
1026
+ """
1027
+ lolp = _get_master_param(data, "loss of load penalty")
1028
+ count = 0
1029
+ for unit in data.units:
1030
+ if not unit.inflow_profile or not unit.output_node:
1031
+ continue
1032
+
1033
+ # Units with eff_charge get their inflow on the storage node
1034
+ # via _write_storage_units (pumped hydro pattern)
1035
+ if _has_eff_charge(unit, data):
1036
+ continue
1037
+
1038
+ inflow_ts = _find_inflow_profile(data, unit.inflow_profile)
1039
+ if inflow_ts is None or not inflow_ts.data:
1040
+ logger.warning(
1041
+ "Inflow profile '%s' not found for unit '%s'.",
1042
+ unit.inflow_profile, _get_unit_name(unit),
1043
+ )
1044
+ continue
1045
+
1046
+ unit_name = _get_unit_name(unit)
1047
+ inflow_node = _get_unit_node_name(unit)
1048
+
1049
+ # Apply inflow multiplier if set
1050
+ ts_data = inflow_ts.data
1051
+ if unit.inflow_multiplier is not None and unit.inflow_multiplier != 0:
1052
+ ts_data = {k: v * unit.inflow_multiplier for k, v in ts_data.items()}
1053
+ inflow_map = _make_time_map(ts_data)
1054
+
1055
+ # Create the inflow node. Always 'storage' — even with 0 capacity this
1056
+ # just creates an unused state variable but allows sensitivities to add
1057
+ # storage capacity later without needing to also change node_type.
1058
+ _add_entity(db, "node", inflow_node, alt_name, counters,
1059
+ entities_added, entity_alts_added)
1060
+ _add_param(db, "node", (inflow_node,), "node_type", "storage",
1061
+ alt_name, counters)
1062
+ if lolp > 0:
1063
+ _add_param(db, "node", (inflow_node,), "penalty_up", lolp,
1064
+ alt_name, counters)
1065
+ _add_param(db, "node", (inflow_node,), "penalty_down", 0.0,
1066
+ alt_name, counters)
1067
+ _add_param_if_set(db, "node", (inflow_node,), "existing",
1068
+ unit.storage_mwh, alt_name, counters, skip_zero=False)
1069
+ _add_param(db, "node", (inflow_node,), "storage_binding_method",
1070
+ "bind_within_solve", alt_name, counters)
1071
+ # Self-discharge loss from unit_type
1072
+ self_discharge = _get_unit_type_param(data, unit, "self discharge loss")
1073
+ _add_param_if_set(db, "node", (inflow_node,), "self_discharge_loss",
1074
+ self_discharge, alt_name, counters)
1075
+ # Storage state start
1076
+ _add_param_if_set(db, "node", (inflow_node,), "storage_state_start",
1077
+ unit.storage_start, alt_name, counters, skip_zero=False)
1078
+
1079
+ # Set inflow time series on the node
1080
+ _add_param(db, "node", (inflow_node,), "inflow", inflow_map,
1081
+ alt_name, counters)
1082
+ _add_param(db, "node", (inflow_node,), "inflow_method",
1083
+ "use_original", alt_name, counters)
1084
+
1085
+ # Connect as unit__inputNode
1086
+ _add_relationship(db, "unit__inputNode", (unit_name, inflow_node),
1087
+ alt_name, counters, entities_added, entity_alts_added)
1088
+
1089
+ count += 1
1090
+
1091
+ # Create standalone inflow nodes for profiles not referenced by any unit
1092
+ used_profiles = {unit.inflow_profile for unit in data.units if unit.inflow_profile}
1093
+ for inflow_ts in data.inflow_profiles:
1094
+ if inflow_ts.name in used_profiles:
1095
+ continue
1096
+ if not inflow_ts.data:
1097
+ continue
1098
+
1099
+ node_name = f"{inflow_ts.name}_node"
1100
+ inflow_map = _make_time_map(inflow_ts.data)
1101
+
1102
+ # Create the entity but set it inactive — it's not connected to any
1103
+ # unit, so having it active would cause infeasibilities/penalties.
1104
+ key = ("node", (node_name,))
1105
+ if key not in entities_added:
1106
+ entities_added.add(key)
1107
+ try:
1108
+ db.add_entity(entity_class_name="node", name=node_name)
1109
+ counters.entities += 1
1110
+ except SpineDBAPIError:
1111
+ pass
1112
+ ea_key = ("node", (node_name,), alt_name)
1113
+ if ea_key not in entity_alts_added:
1114
+ entity_alts_added.add(ea_key)
1115
+ try:
1116
+ db.add_entity_alternative(
1117
+ entity_class_name="node",
1118
+ entity_byname=(node_name,),
1119
+ alternative_name=alt_name,
1120
+ active=False,
1121
+ )
1122
+ counters.entity_alternatives += 1
1123
+ except SpineDBAPIError:
1124
+ pass
1125
+
1126
+ _add_param(db, "node", (node_name,), "node_type", "storage",
1127
+ alt_name, counters)
1128
+ if lolp > 0:
1129
+ _add_param(db, "node", (node_name,), "penalty_up", lolp,
1130
+ alt_name, counters)
1131
+ _add_param(db, "node", (node_name,), "penalty_down", 0.0,
1132
+ alt_name, counters)
1133
+ _add_param(db, "node", (node_name,), "storage_binding_method",
1134
+ "bind_within_solve", alt_name, counters)
1135
+ _add_param(db, "node", (node_name,), "inflow", inflow_map,
1136
+ alt_name, counters)
1137
+ _add_param(db, "node", (node_name,), "inflow_method",
1138
+ "use_original", alt_name, counters)
1139
+ count += 1
1140
+ logger.info("Created standalone inflow node '%s' (inactive — not connected to any unit).", node_name)
1141
+
1142
+ logger.info("Wrote %d inflow nodes total.", count)
1143
+
1144
+
1145
+ def _write_connections(
1146
+ data: OldFlexToolData,
1147
+ db: DatabaseMapping,
1148
+ alt_name: str,
1149
+ counters: _Counters,
1150
+ entities_added: set[tuple[str, tuple[str, ...]]],
1151
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1152
+ ) -> None:
1153
+ """Write nodeNode connections (Section 7)."""
1154
+ for conn in data.connections:
1155
+ conn_name = f"{conn.node1}_{conn.node2}"
1156
+
1157
+ _add_entity(db, "connection", conn_name, alt_name, counters,
1158
+ entities_added, entity_alts_added)
1159
+
1160
+ # existing capacity
1161
+ cap = conn.cap_rightward_mw or 0.0
1162
+ if conn.cap_leftward_mw is not None:
1163
+ cap = max(cap, conn.cap_leftward_mw)
1164
+ _add_param_if_set(db, "connection", (conn_name,), "existing", cap,
1165
+ alt_name, counters)
1166
+
1167
+ # efficiency = 1 - loss
1168
+ if conn.loss is not None:
1169
+ eff = 1.0 - conn.loss
1170
+ _add_param(db, "connection", (conn_name,), "efficiency", eff,
1171
+ alt_name, counters)
1172
+
1173
+ _add_param(db, "connection", (conn_name,), "transfer_method",
1174
+ "regular", alt_name, counters)
1175
+
1176
+ _add_param_if_set(db, "connection", (conn_name,), "invest_cost",
1177
+ conn.invest_cost_per_kw, alt_name, counters)
1178
+ _add_param_if_set(db, "connection", (conn_name,), "lifetime",
1179
+ conn.lifetime, alt_name, counters)
1180
+ _add_param_if_set(db, "connection", (conn_name,), "discount_rate",
1181
+ conn.interest, alt_name, counters)
1182
+
1183
+ has_forced_invest = (
1184
+ conn.invested_capacity_mw is not None and conn.invested_capacity_mw > 0
1185
+ )
1186
+ has_max_invest = (
1187
+ conn.max_invest_mw is not None and conn.max_invest_mw > 0
1188
+ )
1189
+ if has_forced_invest:
1190
+ _add_param(db, "connection", (conn_name,), "invest_method",
1191
+ "invest_total", alt_name, counters)
1192
+ _add_param(db, "connection", (conn_name,), "invest_max_total",
1193
+ conn.invested_capacity_mw, alt_name, counters)
1194
+ _add_param(db, "connection", (conn_name,), "invest_min_total",
1195
+ conn.invested_capacity_mw, alt_name, counters)
1196
+ elif has_max_invest:
1197
+ _add_param(db, "connection", (conn_name,), "invest_method",
1198
+ "invest_total", alt_name, counters)
1199
+ _add_param(db, "connection", (conn_name,), "invest_max_total",
1200
+ conn.max_invest_mw, alt_name, counters)
1201
+
1202
+ if conn.is_hvdc:
1203
+ _add_param(db, "connection", (conn_name,), "is_DC", "yes",
1204
+ alt_name, counters)
1205
+
1206
+ # connection__node__node relationship
1207
+ _add_relationship(db, "connection__node__node",
1208
+ (conn_name, conn.node1, conn.node2),
1209
+ alt_name, counters, entities_added, entity_alts_added)
1210
+
1211
+ logger.info("Wrote %d connections.", len(data.connections))
1212
+
1213
+
1214
+ def _write_groups(
1215
+ data: OldFlexToolData,
1216
+ db: DatabaseMapping,
1217
+ alt_name: str,
1218
+ counters: _Counters,
1219
+ entities_added: set[tuple[str, tuple[str, ...]]],
1220
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1221
+ ) -> None:
1222
+ """Write groups: grid groups, node groups, unit groups, CO2 group (Section 8)."""
1223
+ use_cap_margin = _get_master_param(data, "use capacity margin") == 1
1224
+ use_non_sync = _get_master_param(data, "use non synchronous") == 1
1225
+ use_inertia = _get_master_param(data, "use inertia limit") == 1
1226
+ lack_cap_penalty = _get_master_param(data, "lack of capacity penalty")
1227
+ lack_inertia_penalty = _get_master_param(data, "lack of inertia penalty")
1228
+ loss_reserves_penalty = _get_master_param(data, "loss of reserves penalty")
1229
+
1230
+ # -- Grid groups --
1231
+ grids: dict[str, list[str]] = {}
1232
+ for gn in data.grid_nodes:
1233
+ grids.setdefault(gn.grid, []).append(gn.node)
1234
+
1235
+ for grid_name, nodes in grids.items():
1236
+ _add_entity(db, "group", grid_name, alt_name, counters,
1237
+ entities_added, entity_alts_added)
1238
+ _add_param(db, "group", (grid_name,), "output_nodeGroup_indicators", "yes",
1239
+ alt_name, counters)
1240
+ _add_param(db, "group", (grid_name,), "output_nodeGroup_dispatch", "yes",
1241
+ alt_name, counters)
1242
+ for node_name in nodes:
1243
+ _add_relationship(db, "group__node", (grid_name, node_name),
1244
+ alt_name, counters, entities_added, entity_alts_added)
1245
+
1246
+ logger.info("Wrote %d grid groups.", len(grids))
1247
+
1248
+ # -- Node groups --
1249
+ # Build a lookup: node_group_name -> list of node names (from gridNode)
1250
+ ng_nodes: dict[str, list[str]] = {}
1251
+ for gn in data.grid_nodes:
1252
+ for ng_name in gn.node_groups:
1253
+ ng_nodes.setdefault(ng_name, []).append(gn.node)
1254
+
1255
+ for ng in data.node_groups:
1256
+ has_constraints = (
1257
+ (ng.capacity_margin_mw is not None and ng.capacity_margin_mw > 0)
1258
+ or (ng.non_synchronous_share is not None and ng.non_synchronous_share > 0)
1259
+ or (ng.inertia_limit_mws is not None and ng.inertia_limit_mws > 0)
1260
+ )
1261
+ if not has_constraints:
1262
+ continue
1263
+
1264
+ _add_entity(db, "group", ng.name, alt_name, counters,
1265
+ entities_added, entity_alts_added)
1266
+
1267
+ if use_cap_margin and ng.capacity_margin_mw is not None and ng.capacity_margin_mw > 0:
1268
+ _add_param(db, "group", (ng.name,), "capacity_margin",
1269
+ ng.capacity_margin_mw, alt_name, counters)
1270
+ _add_param(db, "group", (ng.name,), "has_capacity_margin", "yes",
1271
+ alt_name, counters)
1272
+ if lack_cap_penalty > 0:
1273
+ _add_param(db, "group", (ng.name,), "penalty_capacity_margin",
1274
+ lack_cap_penalty, alt_name, counters)
1275
+
1276
+ if use_non_sync and ng.non_synchronous_share is not None and ng.non_synchronous_share > 0:
1277
+ _add_param(db, "group", (ng.name,), "non_synchronous_limit",
1278
+ ng.non_synchronous_share, alt_name, counters)
1279
+ _add_param(db, "group", (ng.name,), "has_non_synchronous", "yes",
1280
+ alt_name, counters)
1281
+ if loss_reserves_penalty > 0:
1282
+ _add_param(db, "group", (ng.name,), "penalty_non_synchronous",
1283
+ loss_reserves_penalty, alt_name, counters)
1284
+
1285
+ if use_inertia and ng.inertia_limit_mws is not None and ng.inertia_limit_mws > 0:
1286
+ _add_param(db, "group", (ng.name,), "inertia_limit",
1287
+ ng.inertia_limit_mws, alt_name, counters)
1288
+ _add_param(db, "group", (ng.name,), "has_inertia", "yes",
1289
+ alt_name, counters)
1290
+ if lack_inertia_penalty > 0:
1291
+ _add_param(db, "group", (ng.name,), "penalty_inertia",
1292
+ lack_inertia_penalty, alt_name, counters)
1293
+
1294
+ # Link nodes to this group
1295
+ for node_name in ng_nodes.get(ng.name, []):
1296
+ _add_relationship(db, "group__node", (ng.name, node_name),
1297
+ alt_name, counters, entities_added, entity_alts_added)
1298
+
1299
+ logger.info("Wrote %d node groups.", len(data.node_groups))
1300
+
1301
+ # -- Unit groups --
1302
+ # Build lookup: group_name -> list of UnitInstance
1303
+ ug_units: dict[str, list[UnitInstance]] = {}
1304
+ for unit in data.units:
1305
+ if unit.unit_group:
1306
+ ug_units.setdefault(unit.unit_group, []).append(unit)
1307
+
1308
+ for ug in data.unit_groups:
1309
+ _add_entity(db, "group", ug.name, alt_name, counters,
1310
+ entities_added, entity_alts_added)
1311
+
1312
+ has_invest_limits = (
1313
+ (ug.max_invest_mw is not None and ug.max_invest_mw > 0)
1314
+ or (ug.min_invest_mw is not None and ug.min_invest_mw > 0)
1315
+ )
1316
+ if has_invest_limits:
1317
+ _add_param(db, "group", (ug.name,), "invest_method", "invest_total",
1318
+ alt_name, counters)
1319
+ _add_param_if_set(db, "group", (ug.name,), "invest_max_total",
1320
+ ug.max_invest_mw, alt_name, counters)
1321
+ _add_param_if_set(db, "group", (ug.name,), "invest_min_total",
1322
+ ug.min_invest_mw, alt_name, counters)
1323
+
1324
+ _add_param(db, "group", (ug.name,), "flow_aggregator", "yes",
1325
+ alt_name, counters)
1326
+
1327
+ # Link units to this group
1328
+ for unit in ug_units.get(ug.name, []):
1329
+ if unit.output_node is None:
1330
+ continue
1331
+ if _needs_charger_discharger(unit, data):
1332
+ # Storage units are represented as charger + discharger
1333
+ unit_name = _get_unit_name(unit)
1334
+ charger = f"{unit_name}_charger"
1335
+ discharger = f"{unit_name}_discharger"
1336
+ _add_relationship(db, "group__unit", (ug.name, charger),
1337
+ alt_name, counters, entities_added, entity_alts_added)
1338
+ _add_relationship(db, "group__unit__node",
1339
+ (ug.name, charger, unit.output_node),
1340
+ alt_name, counters, entities_added, entity_alts_added)
1341
+ _add_relationship(db, "group__unit", (ug.name, discharger),
1342
+ alt_name, counters, entities_added, entity_alts_added)
1343
+ _add_relationship(db, "group__unit__node",
1344
+ (ug.name, discharger, unit.output_node),
1345
+ alt_name, counters, entities_added, entity_alts_added)
1346
+ else:
1347
+ unit_name = _get_unit_name(unit)
1348
+ _add_relationship(db, "group__unit", (ug.name, unit_name),
1349
+ alt_name, counters, entities_added, entity_alts_added)
1350
+ _add_relationship(db, "group__unit__node",
1351
+ (ug.name, unit_name, unit.output_node),
1352
+ alt_name, counters, entities_added, entity_alts_added)
1353
+
1354
+ logger.info("Wrote %d unit groups.", len(data.unit_groups))
1355
+
1356
+ # -- CO2 group --
1357
+ co2_cost = _get_master_param(data, "co2 cost")
1358
+ if co2_cost > 0:
1359
+ _add_entity(db, "group", "co2_price", alt_name, counters,
1360
+ entities_added, entity_alts_added)
1361
+ _add_param(db, "group", ("co2_price",), "co2_method", "price",
1362
+ alt_name, counters)
1363
+ _add_param(db, "group", ("co2_price",), "co2_price", co2_cost,
1364
+ alt_name, counters)
1365
+
1366
+ # Link all fuel market nodes
1367
+ used_fuels: set[str] = set()
1368
+ for unit in data.units:
1369
+ if unit.fuel:
1370
+ used_fuels.add(unit.fuel)
1371
+ for fuel_name in used_fuels:
1372
+ market_name = _fuel_market_name(fuel_name)
1373
+ _add_relationship(db, "group__node", ("co2_price", market_name),
1374
+ alt_name, counters, entities_added, entity_alts_added)
1375
+
1376
+ logger.info("Wrote CO2 group with cost=%.2f.", co2_cost)
1377
+
1378
+
1379
+ def _find_reserve_node_ts(
1380
+ data: OldFlexToolData, node_name: str,
1381
+ ) -> TimeSeriesData | None:
1382
+ """Find a reserve time series for a specific node."""
1383
+ for ts in data.reserve_node_ts:
1384
+ if ts.name == node_name:
1385
+ return ts
1386
+ return None
1387
+
1388
+
1389
+ def _find_reserve_group_ts(
1390
+ data: OldFlexToolData, group_name: str,
1391
+ ) -> TimeSeriesData | None:
1392
+ """Find a reserve time series for a specific group."""
1393
+ for ts in data.reserve_group_ts:
1394
+ if ts.name == group_name:
1395
+ return ts
1396
+ return None
1397
+
1398
+
1399
+ def _get_reserve_method(
1400
+ use_ts_reserve: float | None,
1401
+ use_dynamic_reserve: float | None,
1402
+ ) -> str | None:
1403
+ """Map old boolean flags to the new reserve_method string.
1404
+
1405
+ Returns None for "no_reserve" (both flags off) to signal the caller
1406
+ should skip creating this reserve.
1407
+ """
1408
+ ts = use_ts_reserve is not None and use_ts_reserve == 1
1409
+ dyn = use_dynamic_reserve is not None and use_dynamic_reserve == 1
1410
+ if ts and dyn:
1411
+ return "timeseries_and_dynamic"
1412
+ if ts:
1413
+ return "timeseries_only"
1414
+ if dyn:
1415
+ return "dynamic_only"
1416
+ return None # no_reserve — skip
1417
+
1418
+
1419
+ def _write_reserves(
1420
+ data: OldFlexToolData,
1421
+ db: DatabaseMapping,
1422
+ alt_name: str,
1423
+ counters: _Counters,
1424
+ entities_added: set[tuple[str, tuple[str, ...]]],
1425
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1426
+ ) -> None:
1427
+ """Write reserve entities, relationships, and parameters (Section 8b).
1428
+
1429
+ Creates the "primary" reserve entity and builds reserve__upDown__group
1430
+ and reserve__upDown__unit__node relationships from old FlexTool reserve
1431
+ data (node groups, grid nodes, unit types, and reserve time series).
1432
+ """
1433
+ penalty_reserve = _get_master_param(data, "loss of reserves penalty")
1434
+
1435
+ # 1. Create the "primary" reserve and "up" upDown entities
1436
+ # (both exist in the template but are removed by purge)
1437
+ _add_entity(db, "reserve", "primary", alt_name, counters,
1438
+ entities_added, entity_alts_added)
1439
+ _add_entity(db, "upDown", "up", alt_name, counters,
1440
+ entities_added, entity_alts_added)
1441
+
1442
+ # Build lookup: node_group_name -> list of node names (from gridNode)
1443
+ ng_nodes: dict[str, list[str]] = {}
1444
+ for gn in data.grid_nodes:
1445
+ for ng_name in gn.node_groups:
1446
+ ng_nodes.setdefault(ng_name, []).append(gn.node)
1447
+
1448
+ # Track which nodes are covered by a group-level reserve
1449
+ # and which group each node belongs to (for unit matching)
1450
+ nodes_with_reserves: set[str] = set()
1451
+ node_to_reserve_group: dict[str, str] = {}
1452
+ reserve_count = 0
1453
+
1454
+ # 2. Node groups with reserves
1455
+ for ng in data.node_groups:
1456
+ reserve_method = _get_reserve_method(ng.use_ts_reserve, ng.use_dynamic_reserve)
1457
+ if reserve_method is None:
1458
+ continue
1459
+
1460
+ group_name = ng.name
1461
+
1462
+ # The group entity may not exist yet (if it had no capacity/inertia
1463
+ # constraints, _write_groups would have skipped it).
1464
+ _add_entity(db, "group", group_name, alt_name, counters,
1465
+ entities_added, entity_alts_added)
1466
+
1467
+ # Ensure group__node links exist for this group
1468
+ for node_name in ng_nodes.get(group_name, []):
1469
+ _add_relationship(db, "group__node", (group_name, node_name),
1470
+ alt_name, counters, entities_added, entity_alts_added)
1471
+
1472
+ # Create reserve__upDown__group relationship
1473
+ rel_elements = ("primary", "up", group_name)
1474
+ _add_relationship(db, "reserve__upDown__group", rel_elements,
1475
+ alt_name, counters, entities_added, entity_alts_added)
1476
+
1477
+ # Set reserve_method
1478
+ _add_param(db, "reserve__upDown__group", rel_elements,
1479
+ "reserve_method", reserve_method, alt_name, counters)
1480
+
1481
+ # Set penalty_reserve from master params
1482
+ _add_param_if_set(db, "reserve__upDown__group", rel_elements,
1483
+ "penalty_reserve", penalty_reserve, alt_name, counters)
1484
+
1485
+ # Reserve time series for this group
1486
+ group_ts = _find_reserve_group_ts(data, group_name)
1487
+ if group_ts and group_ts.data:
1488
+ reservation_map = _make_time_map(group_ts.data)
1489
+ _add_param(db, "reserve__upDown__group", rel_elements,
1490
+ "reservation", reservation_map, alt_name, counters)
1491
+
1492
+ # Record which nodes are covered
1493
+ for node_name in ng_nodes.get(group_name, []):
1494
+ nodes_with_reserves.add(node_name)
1495
+ node_to_reserve_group[node_name] = group_name
1496
+
1497
+ reserve_count += 1
1498
+
1499
+ logger.info("Wrote %d node-group reserves.", reserve_count)
1500
+
1501
+ # 3. Grid nodes with reserves (only if not already covered by a nodeGroup)
1502
+ node_reserve_count = 0
1503
+ for gn in data.grid_nodes:
1504
+ # Skip if this node is already in a reserve-enabled group
1505
+ if gn.node in nodes_with_reserves:
1506
+ continue
1507
+
1508
+ reserve_method = _get_reserve_method(gn.use_ts_reserve, gn.use_dynamic_reserve)
1509
+ if reserve_method is None:
1510
+ continue
1511
+
1512
+ # Create a single-node reserve group
1513
+ reserve_group_name = f"reserve_{gn.node}"
1514
+
1515
+ _add_entity(db, "group", reserve_group_name, alt_name, counters,
1516
+ entities_added, entity_alts_added)
1517
+
1518
+ # Link node to the new group
1519
+ _add_relationship(db, "group__node", (reserve_group_name, gn.node),
1520
+ alt_name, counters, entities_added, entity_alts_added)
1521
+
1522
+ # Create reserve__upDown__group relationship
1523
+ rel_elements = ("primary", "up", reserve_group_name)
1524
+ _add_relationship(db, "reserve__upDown__group", rel_elements,
1525
+ alt_name, counters, entities_added, entity_alts_added)
1526
+
1527
+ # Set reserve_method
1528
+ _add_param(db, "reserve__upDown__group", rel_elements,
1529
+ "reserve_method", reserve_method, alt_name, counters)
1530
+
1531
+ # Set penalty_reserve
1532
+ _add_param_if_set(db, "reserve__upDown__group", rel_elements,
1533
+ "penalty_reserve", penalty_reserve, alt_name, counters)
1534
+
1535
+ # Reserve time series for this node
1536
+ node_ts = _find_reserve_node_ts(data, gn.node)
1537
+ if node_ts and node_ts.data:
1538
+ reservation_map = _make_time_map(node_ts.data)
1539
+ _add_param(db, "reserve__upDown__group", rel_elements,
1540
+ "reservation", reservation_map, alt_name, counters)
1541
+
1542
+ # Record this node as having reserves
1543
+ nodes_with_reserves.add(gn.node)
1544
+ node_to_reserve_group[gn.node] = reserve_group_name
1545
+ node_reserve_count += 1
1546
+
1547
+ logger.info("Wrote %d single-node reserve groups.", node_reserve_count)
1548
+
1549
+ # 5. Unit reserve participation
1550
+ unit_reserve_count = 0
1551
+ for unit in data.units:
1552
+ if unit.output_node is None:
1553
+ continue
1554
+
1555
+ # Look up max_reserve from unit_type
1556
+ max_reserve = _get_unit_type_param(data, unit, "max reserve")
1557
+ if max_reserve is None or max_reserve == 0:
1558
+ continue
1559
+
1560
+ # Determine which node to use: check output_node first, then input_node
1561
+ # (skip output2 — reserves are electrical grid only)
1562
+ reserve_node: str | None = None
1563
+ if unit.output_node in nodes_with_reserves:
1564
+ reserve_node = unit.output_node
1565
+ elif unit.input_node is not None and unit.input_node in nodes_with_reserves:
1566
+ reserve_node = unit.input_node
1567
+
1568
+ if reserve_node is None:
1569
+ continue
1570
+
1571
+ unit_name = _get_unit_name(unit)
1572
+
1573
+ if _needs_charger_discharger(unit, data):
1574
+ # Both charger and discharger participate in reserves
1575
+ for sub_name in (f"{unit_name}_charger", f"{unit_name}_discharger"):
1576
+ rel_elements = ("primary", "up", sub_name, reserve_node)
1577
+ _add_relationship(db, "reserve__upDown__unit__node", rel_elements,
1578
+ alt_name, counters, entities_added, entity_alts_added)
1579
+ _add_param(db, "reserve__upDown__unit__node", rel_elements,
1580
+ "max_share", max_reserve, alt_name, counters)
1581
+ _add_param_if_set(db, "reserve__upDown__unit__node", rel_elements,
1582
+ "increase_reserve_ratio",
1583
+ unit.reserve_increase_ratio, alt_name, counters)
1584
+ unit_reserve_count += 1
1585
+ else:
1586
+ rel_elements = ("primary", "up", unit_name, reserve_node)
1587
+ _add_relationship(db, "reserve__upDown__unit__node", rel_elements,
1588
+ alt_name, counters, entities_added, entity_alts_added)
1589
+ _add_param(db, "reserve__upDown__unit__node", rel_elements,
1590
+ "max_share", max_reserve, alt_name, counters)
1591
+ _add_param_if_set(db, "reserve__upDown__unit__node", rel_elements,
1592
+ "increase_reserve_ratio",
1593
+ unit.reserve_increase_ratio, alt_name, counters)
1594
+ unit_reserve_count += 1
1595
+
1596
+ logger.info("Wrote %d unit reserve participations.", unit_reserve_count)
1597
+
1598
+
1599
+ def _write_chp_constraints(
1600
+ data: OldFlexToolData,
1601
+ db: DatabaseMapping,
1602
+ alt_name: str,
1603
+ counters: _Counters,
1604
+ entities_added: set[tuple[str, tuple[str, ...]]],
1605
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1606
+ ) -> None:
1607
+ """Write CHP constraints for units with output2 (Section 9).
1608
+
1609
+ Collects all constraint names/coefficients per (unit, node) pair and writes
1610
+ a single Map per unit__outputNode, so multiple constraints don't overwrite
1611
+ each other.
1612
+ """
1613
+ count = 0
1614
+ for unit in data.units:
1615
+ if not unit.output2_node or not unit.output_node:
1616
+ continue
1617
+
1618
+ unit_name = _get_unit_name(unit)
1619
+ primary_node = unit.output_node
1620
+ secondary_node = unit.output2_node
1621
+
1622
+ # Collect coefficients: {(unit_name, node): {constraint_name: coeff}}
1623
+ primary_coeffs: dict[str, float] = {}
1624
+ secondary_coeffs: dict[str, float] = {}
1625
+
1626
+ # Equal constraint
1627
+ if unit.output2_eq_coeff is not None and unit.output2_eq_coeff != 0:
1628
+ cname = f"{unit_name}_eq"
1629
+ _add_entity(db, "constraint", cname, alt_name, counters,
1630
+ entities_added, entity_alts_added)
1631
+ _add_param(db, "constraint", (cname,), "sense", "equal",
1632
+ alt_name, counters)
1633
+ constant = unit.output2_eq_constant if unit.output2_eq_constant is not None else 0.0
1634
+ _add_param(db, "constraint", (cname,), "constant", constant,
1635
+ alt_name, counters)
1636
+ primary_coeffs[cname] = 1.0
1637
+ secondary_coeffs[cname] = -unit.output2_eq_coeff
1638
+ count += 1
1639
+
1640
+ # Greater-than constraint
1641
+ if unit.output2_gt_coeff is not None and unit.output2_gt_coeff != 0:
1642
+ cname = f"{unit_name}_gt"
1643
+ _add_entity(db, "constraint", cname, alt_name, counters,
1644
+ entities_added, entity_alts_added)
1645
+ _add_param(db, "constraint", (cname,), "sense", "greater_than",
1646
+ alt_name, counters)
1647
+ constant = unit.output2_gt_constant if unit.output2_gt_constant is not None else 0.0
1648
+ _add_param(db, "constraint", (cname,), "constant", constant,
1649
+ alt_name, counters)
1650
+ primary_coeffs[cname] = 1.0
1651
+ secondary_coeffs[cname] = -unit.output2_gt_coeff
1652
+ count += 1
1653
+
1654
+ # Less-than constraint
1655
+ if unit.output2_lt_coeff is not None and unit.output2_lt_coeff != 0:
1656
+ cname = f"{unit_name}_lt"
1657
+ _add_entity(db, "constraint", cname, alt_name, counters,
1658
+ entities_added, entity_alts_added)
1659
+ _add_param(db, "constraint", (cname,), "sense", "less_than",
1660
+ alt_name, counters)
1661
+ constant = unit.output2_lt_constant if unit.output2_lt_constant is not None else 0.0
1662
+ _add_param(db, "constraint", (cname,), "constant", constant,
1663
+ alt_name, counters)
1664
+ primary_coeffs[cname] = 1.0
1665
+ secondary_coeffs[cname] = -unit.output2_lt_coeff
1666
+ count += 1
1667
+
1668
+ # Write combined Maps for constraint_flow_coeff
1669
+ if primary_coeffs:
1670
+ names = list(primary_coeffs.keys())
1671
+ pmap = Map(names, [primary_coeffs[n] for n in names],
1672
+ index_name="constraint")
1673
+ _add_param(db, "unit__outputNode", (unit_name, primary_node),
1674
+ "constraint_flow_coeff", pmap, alt_name, counters)
1675
+
1676
+ snames = list(secondary_coeffs.keys())
1677
+ smap = Map(snames, [secondary_coeffs[n] for n in snames],
1678
+ index_name="constraint")
1679
+ _add_param(db, "unit__outputNode", (unit_name, secondary_node),
1680
+ "constraint_flow_coeff", smap, alt_name, counters)
1681
+
1682
+ logger.info("Wrote %d CHP constraints.", count)
1683
+
1684
+
1685
+ def _write_timeline_and_solve(
1686
+ data: OldFlexToolData,
1687
+ db: DatabaseMapping,
1688
+ alt_name: str,
1689
+ counters: _Counters,
1690
+ entities_added: set[tuple[str, tuple[str, ...]]],
1691
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
1692
+ ) -> None:
1693
+ """Write timeline, timeset, solve, and model entities (Sections 10)."""
1694
+ if not data.time_steps:
1695
+ logger.warning("No time steps found; skipping timeline/solve creation.")
1696
+ return
1697
+
1698
+ time_period_duration = _get_master_param(data, "time period duration")
1699
+ duration_hours = time_period_duration / 60.0 if time_period_duration > 0 else 1.0
1700
+
1701
+ # -- Timeline --
1702
+ _add_entity(db, "timeline", "y2020", alt_name, counters,
1703
+ entities_added, entity_alts_added)
1704
+
1705
+ # timestep_duration: Map of ALL time step IDs -> duration_hours
1706
+ all_time_ids = [ts.time_id for ts in data.time_steps]
1707
+ all_durations = [duration_hours] * len(all_time_ids)
1708
+ timestep_map = Map(all_time_ids, all_durations, index_name="time")
1709
+ _add_param(db, "timeline", ("y2020",), "timestep_duration", timestep_map,
1710
+ alt_name, counters)
1711
+
1712
+ # -- Timeset (dispatch) --
1713
+ _add_entity(db, "timeset", "dispatch_set", alt_name, counters,
1714
+ entities_added, entity_alts_added)
1715
+ _add_param(db, "timeset", ("dispatch_set",), "timeline", "y2020",
1716
+ alt_name, counters)
1717
+
1718
+ # Build timeset_duration from contiguous blocks of in_use=True
1719
+ dispatch_duration = _build_timeset_duration(data.time_steps, "dispatch", duration_hours)
1720
+ _add_param(db, "timeset", ("dispatch_set",), "timeset_duration",
1721
+ dispatch_duration, alt_name, counters)
1722
+
1723
+ # -- Timeset (invest) if in_use_invest differs --
1724
+ has_invest_ts = any(ts.in_use_invest != ts.in_use for ts in data.time_steps)
1725
+ invest_set_name = "invest_set" if has_invest_ts else "dispatch_set"
1726
+
1727
+ if has_invest_ts:
1728
+ _add_entity(db, "timeset", "invest_set", alt_name, counters,
1729
+ entities_added, entity_alts_added)
1730
+ _add_param(db, "timeset", ("invest_set",), "timeline", "y2020",
1731
+ alt_name, counters)
1732
+ invest_duration = _build_timeset_duration(data.time_steps, "invest", duration_hours)
1733
+ _add_param(db, "timeset", ("invest_set",), "timeset_duration",
1734
+ invest_duration, alt_name, counters)
1735
+
1736
+ # -- Solve entities (always create both dispatch and invest) --
1737
+ mode_invest = _get_master_param(data, "mode invest") == 1
1738
+ mode_dispatch = _get_master_param(data, "mode dispatch") == 1
1739
+
1740
+ # Dispatch solve
1741
+ _add_entity(db, "solve", "dispatch", alt_name, counters,
1742
+ entities_added, entity_alts_added)
1743
+ _add_param(db, "solve", ("dispatch",), "solve_mode", "single_solve",
1744
+ alt_name, counters)
1745
+ _add_param(db, "solve", ("dispatch",), "period_timeset",
1746
+ Map(["p2020"], ["dispatch_set"], index_name="period"),
1747
+ alt_name, counters)
1748
+ _add_param(db, "solve", ("dispatch",), "realized_periods",
1749
+ Array(["p2020"]), alt_name, counters)
1750
+ _add_param(db, "solve", ("dispatch",), "solver", "highs",
1751
+ alt_name, counters)
1752
+
1753
+ # Invest solve
1754
+ _add_entity(db, "solve", "invest", alt_name, counters,
1755
+ entities_added, entity_alts_added)
1756
+ _add_param(db, "solve", ("invest",), "solve_mode", "single_solve",
1757
+ alt_name, counters)
1758
+ _add_param(db, "solve", ("invest",), "period_timeset",
1759
+ Map(["p2020"], [invest_set_name], index_name="period"),
1760
+ alt_name, counters)
1761
+ _add_param(db, "solve", ("invest",), "invest_periods",
1762
+ Array(["p2020"]), alt_name, counters)
1763
+ _add_param(db, "solve", ("invest",), "realized_invest_periods",
1764
+ Array(["p2020"]), alt_name, counters)
1765
+ # realized_periods on invest only when dispatch is not active
1766
+ # (when dispatch is active, realization happens in the dispatch solve)
1767
+ if not mode_dispatch:
1768
+ _add_param(db, "solve", ("invest",), "realized_periods",
1769
+ Array(["p2020"]), alt_name, counters)
1770
+ _add_param(db, "solve", ("invest",), "solver", "highs",
1771
+ alt_name, counters)
1772
+
1773
+ # Model solves based on active modes
1774
+ if mode_invest and mode_dispatch:
1775
+ # Invest nests dispatch
1776
+ _add_param(db, "solve", ("invest",), "contains_solves", "dispatch",
1777
+ alt_name, counters)
1778
+ model_solves = Array(["invest"])
1779
+ elif mode_invest:
1780
+ # Invest only (no nested dispatch)
1781
+ model_solves = Array(["invest"])
1782
+ else:
1783
+ # Dispatch only (default)
1784
+ model_solves = Array(["dispatch"])
1785
+
1786
+ # -- Model --
1787
+ _add_entity(db, "model", "flexTool", alt_name, counters,
1788
+ entities_added, entity_alts_added)
1789
+ _add_param(db, "model", ("flexTool",), "solves", model_solves,
1790
+ alt_name, counters)
1791
+
1792
+ modes = []
1793
+ if mode_invest:
1794
+ modes.append("invest")
1795
+ if mode_dispatch:
1796
+ modes.append("dispatch")
1797
+ logger.info(
1798
+ "Wrote timeline (%d steps), timeset, solve (%s), and model.",
1799
+ len(data.time_steps),
1800
+ "+".join(modes) if modes else "dispatch",
1801
+ )
1802
+
1803
+
1804
+ def _build_timeset_duration(
1805
+ time_steps: list,
1806
+ mode: str,
1807
+ duration_hours: float,
1808
+ ) -> Map:
1809
+ """Build a timeset_duration Map from contiguous blocks of in_use steps.
1810
+
1811
+ Args:
1812
+ time_steps: List of TimeStep objects.
1813
+ mode: "dispatch" (uses in_use) or "invest" (uses in_use_invest).
1814
+ duration_hours: Duration of each time step in hours.
1815
+
1816
+ Returns:
1817
+ A Map where each key is the start time_id of a contiguous block
1818
+ and each value is the total duration of that block in hours.
1819
+ """
1820
+
1821
+ starts: list[str] = []
1822
+ durations: list[float] = []
1823
+ block_start: str | None = None
1824
+ block_count: int = 0
1825
+
1826
+ for ts in time_steps:
1827
+ in_use = ts.in_use if mode == "dispatch" else ts.in_use_invest
1828
+ if in_use:
1829
+ if block_start is None:
1830
+ block_start = ts.time_id
1831
+ block_count = 1
1832
+ else:
1833
+ block_count += 1
1834
+ else:
1835
+ if block_start is not None:
1836
+ starts.append(block_start)
1837
+ durations.append(block_count * duration_hours)
1838
+ block_start = None
1839
+ block_count = 0
1840
+
1841
+ # Close last block if open
1842
+ if block_start is not None:
1843
+ starts.append(block_start)
1844
+ durations.append(block_count * duration_hours)
1845
+
1846
+ return Map(starts, durations, index_name="time")
1847
+
1848
+
1849
+ # ---------------------------------------------------------------------------
1850
+ # Purge helper
1851
+ # ---------------------------------------------------------------------------
1852
+
1853
+
1854
+ def _purge_database(db: DatabaseMapping) -> None:
1855
+ """Remove data from the database before import.
1856
+
1857
+ Purges entities, alternatives, scenarios, and parameter values, but keeps
1858
+ entity classes and parameter definitions (from the template).
1859
+ """
1860
+ from spinedb_api import Asterisk
1861
+
1862
+ try:
1863
+ db.remove_parameter_value(id=Asterisk)
1864
+ db.remove_entity(id=Asterisk)
1865
+ db.remove_alternative(id=Asterisk)
1866
+ db.remove_scenario(id=Asterisk)
1867
+ db.commit_session("Purged database before old FlexTool import")
1868
+ except NothingToCommit:
1869
+ pass
1870
+ except SpineDBAPIError as exc:
1871
+ raise RuntimeError(f"Failed to purge database: {exc}") from exc
1872
+
1873
+
1874
+ # ---------------------------------------------------------------------------
1875
+ # Public API
1876
+ # ---------------------------------------------------------------------------
1877
+
1878
+
1879
+ def write_old_flextool_to_db(
1880
+ data: OldFlexToolData,
1881
+ db_url: str,
1882
+ alternative_name: str = "base",
1883
+ purge: bool = True,
1884
+ ) -> None:
1885
+ """Write old FlexTool data to a Spine database.
1886
+
1887
+ Args:
1888
+ data: Parsed old FlexTool data from read_old_flextool().
1889
+ db_url: Spine database URL (e.g., "sqlite:///path/to/db.sqlite").
1890
+ alternative_name: Name of the alternative to create for this data.
1891
+ purge: If True, purge existing data before import.
1892
+ """
1893
+ logger.info("Opening database: %s", db_url)
1894
+
1895
+ with DatabaseMapping(db_url, create=False, upgrade=True) as db:
1896
+ if purge:
1897
+ logger.info("Purging existing data...")
1898
+ _purge_database(db)
1899
+
1900
+ # Tracking sets to avoid duplicate add calls
1901
+ entities_added: set[tuple[str, tuple[str, ...]]] = set()
1902
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]] = set()
1903
+ counters = _Counters()
1904
+
1905
+ # Add the alternative
1906
+ try:
1907
+ db.add_alternative(name=alternative_name)
1908
+ except SpineDBAPIError:
1909
+ pass # May already exist
1910
+
1911
+ # Write in dependency order:
1912
+ # 1. Commodities and fuel market nodes
1913
+ _write_commodities(data, db, alternative_name, counters,
1914
+ entities_added, entity_alts_added)
1915
+
1916
+ # 2. Balance nodes
1917
+ _write_balance_nodes(data, db, alternative_name, counters,
1918
+ entities_added, entity_alts_added)
1919
+
1920
+ # 3. Storage nodes and connections
1921
+ _write_storage_units(data, db, alternative_name, counters,
1922
+ entities_added, entity_alts_added)
1923
+
1924
+ # 4. Units (depends on nodes existing)
1925
+ _write_units(data, db, alternative_name, counters,
1926
+ entities_added, entity_alts_added)
1927
+
1928
+ # 5. CF Profiles
1929
+ _write_profiles(data, db, alternative_name, counters,
1930
+ entities_added, entity_alts_added)
1931
+
1932
+ # 6. Inflow profiles
1933
+ _write_inflow_units(data, db, alternative_name, counters,
1934
+ entities_added, entity_alts_added)
1935
+
1936
+ # 7. Connections
1937
+ _write_connections(data, db, alternative_name, counters,
1938
+ entities_added, entity_alts_added)
1939
+
1940
+ # 8. Groups (grid, node, unit, CO2)
1941
+ _write_groups(data, db, alternative_name, counters,
1942
+ entities_added, entity_alts_added)
1943
+
1944
+ # 8b. Reserves (depends on groups existing)
1945
+ _write_reserves(data, db, alternative_name, counters,
1946
+ entities_added, entity_alts_added)
1947
+
1948
+ # 9. CHP constraints
1949
+ _write_chp_constraints(data, db, alternative_name, counters,
1950
+ entities_added, entity_alts_added)
1951
+
1952
+ # 10. Timeline, timeset, solve, model
1953
+ _write_timeline_and_solve(data, db, alternative_name, counters,
1954
+ entities_added, entity_alts_added)
1955
+
1956
+ # 11. Create a scenario that uses our alternative
1957
+ db.add_scenario_item(name=alternative_name)
1958
+ db.add_scenario_alternative_item(
1959
+ scenario_name=alternative_name,
1960
+ alternative_name=alternative_name,
1961
+ rank=1,
1962
+ )
1963
+ logger.info("Created scenario '%s'.", alternative_name)
1964
+
1965
+ # Commit everything
1966
+ try:
1967
+ db.commit_session("Import old FlexTool data")
1968
+ logger.info("Successfully committed all data. Summary: %s", counters.summary())
1969
+ except NothingToCommit:
1970
+ logger.info("No new data to commit.")
1971
+ except SpineDBAPIError as exc:
1972
+ raise RuntimeError(f"Failed to commit imported data: {exc}") from exc
1973
+
1974
+
1975
+ # ---------------------------------------------------------------------------
1976
+ # Sensitivity import
1977
+ # ---------------------------------------------------------------------------
1978
+
1979
+
1980
+ def _build_existing_entity_set(db: DatabaseMapping) -> set[tuple[str, tuple[str, ...]]]:
1981
+ """Build a lookup set of (class_name, entity_byname) for all entities in the DB."""
1982
+ existing: set[tuple[str, tuple[str, ...]]] = set()
1983
+ for item in db.get_entity_items():
1984
+ class_name = item["entity_class_name"]
1985
+ byname = tuple(item["entity_byname"])
1986
+ existing.add((class_name, byname))
1987
+ return existing
1988
+
1989
+
1990
+ def _entity_exists(
1991
+ existing_entities: set[tuple[str, tuple[str, ...]]],
1992
+ class_name: str,
1993
+ entity_byname: tuple[str, ...],
1994
+ ) -> bool:
1995
+ """Check if an entity exists in the lookup set."""
1996
+ return (class_name, entity_byname) in existing_entities
1997
+
1998
+
1999
+ def _get_effective_master_params(
2000
+ data: OldFlexToolData,
2001
+ overrides: list[SensitivityOverride],
2002
+ ) -> dict[str, float]:
2003
+ """Merge base master params with all master overrides for a scenario.
2004
+
2005
+ Returns a dict of all master params with overrides applied on top.
2006
+ """
2007
+ params = dict(data.master.params)
2008
+ for ov in overrides:
2009
+ if ov.section == "master" and isinstance(ov.value, (int, float)):
2010
+ params[ov.param_name] = float(ov.value)
2011
+ return params
2012
+
2013
+
2014
+ def _apply_master_override(
2015
+ ov: SensitivityOverride,
2016
+ data: OldFlexToolData,
2017
+ effective_master: dict[str, float],
2018
+ db: DatabaseMapping,
2019
+ alt_name: str,
2020
+ counters: _Counters,
2021
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2022
+ ) -> None:
2023
+ """Apply a single master-section sensitivity override."""
2024
+ param = ov.param_name.lower().replace("_", " ")
2025
+ value = ov.value
2026
+
2027
+ if "co2" in param and "cost" in param:
2028
+ # co2_cost -> group.co2_price on the co2_price group
2029
+ if _entity_exists(existing_entities, "group", ("co2_price",)):
2030
+ _add_param(db, "group", ("co2_price",), "co2_price", value, alt_name, counters)
2031
+ else:
2032
+ logger.warning("co2_price group not found; skipping co2_cost override.")
2033
+
2034
+ elif "loss of load penalty" in param or "loss_of_load_penalty" in ov.param_name.lower():
2035
+ # Set penalty_up and penalty_down on ALL balance nodes
2036
+ for gn in data.grid_nodes:
2037
+ if _entity_exists(existing_entities, "node", (gn.node,)):
2038
+ _add_param(db, "node", (gn.node,), "penalty_up", value, alt_name, counters)
2039
+ _add_param(db, "node", (gn.node,), "penalty_down", value, alt_name, counters)
2040
+
2041
+ elif "loss of reserves penalty" in param or "loss_of_reserves_penalty" in ov.param_name.lower():
2042
+ # Set penalty_reserve on all reserve groups
2043
+ for ng in data.node_groups:
2044
+ reserve_method = _get_reserve_method(ng.use_ts_reserve, ng.use_dynamic_reserve)
2045
+ if reserve_method is not None:
2046
+ rel = ("primary", "up", ng.name)
2047
+ if _entity_exists(existing_entities, "reserve__upDown__group", rel):
2048
+ _add_param(db, "reserve__upDown__group", rel,
2049
+ "penalty_reserve", value, alt_name, counters)
2050
+
2051
+ elif "lack of inertia penalty" in param or "lack_of_inertia_penalty" in ov.param_name.lower():
2052
+ for ng in data.node_groups:
2053
+ if ng.inertia_limit_mws is not None and ng.inertia_limit_mws > 0:
2054
+ if _entity_exists(existing_entities, "group", (ng.name,)):
2055
+ _add_param(db, "group", (ng.name,), "penalty_inertia",
2056
+ value, alt_name, counters)
2057
+
2058
+ elif "lack of capacity penalty" in param or "lack_of_capacity_penalty" in ov.param_name.lower():
2059
+ for ng in data.node_groups:
2060
+ if ng.capacity_margin_mw is not None and ng.capacity_margin_mw > 0:
2061
+ if _entity_exists(existing_entities, "group", (ng.name,)):
2062
+ _add_param(db, "group", (ng.name,), "penalty_capacity_margin",
2063
+ value, alt_name, counters)
2064
+
2065
+ elif "use capacity margin" in param or "use_capacity_margin" in ov.param_name.lower():
2066
+ enabled = float(value) == 1 if isinstance(value, (int, float)) else False
2067
+ for ng in data.node_groups:
2068
+ if ng.capacity_margin_mw is not None and ng.capacity_margin_mw > 0:
2069
+ if _entity_exists(existing_entities, "group", (ng.name,)):
2070
+ if enabled:
2071
+ _add_param(db, "group", (ng.name,), "has_capacity_margin",
2072
+ "yes", alt_name, counters)
2073
+ _add_param(db, "group", (ng.name,), "capacity_margin",
2074
+ ng.capacity_margin_mw, alt_name, counters)
2075
+ else:
2076
+ _add_param(db, "group", (ng.name,), "has_capacity_margin",
2077
+ "no", alt_name, counters)
2078
+
2079
+ elif "use online" in param or "use_online" in ov.param_name.lower():
2080
+ # Affects startup_method on units with startup costs
2081
+ enabled = float(value) == 1 if isinstance(value, (int, float)) else False
2082
+ for unit in data.units:
2083
+ if unit.output_node is None:
2084
+ continue
2085
+ ut = _get_unit_type(data, unit)
2086
+ startup_cost = ut.params.get("startup cost") if ut else None
2087
+ if startup_cost is not None and startup_cost > 0:
2088
+ unit_name = _get_unit_name(unit)
2089
+ if _entity_exists(existing_entities, "unit", (unit_name,)):
2090
+ if enabled:
2091
+ _add_param(db, "unit", (unit_name,), "startup_method",
2092
+ "linear", alt_name, counters)
2093
+ _add_param(db, "unit", (unit_name,), "startup_cost",
2094
+ startup_cost, alt_name, counters)
2095
+ else:
2096
+ _add_param(db, "unit", (unit_name,), "startup_method",
2097
+ "no_startup", alt_name, counters)
2098
+
2099
+ elif "use ramps" in param or "use_ramps" in ov.param_name.lower():
2100
+ enabled = float(value) == 1 if isinstance(value, (int, float)) else False
2101
+ for unit in data.units:
2102
+ if unit.output_node is None:
2103
+ continue
2104
+ ut = _get_unit_type(data, unit)
2105
+ ramp_up = ut.params.get("ramp up (p.u. per min)") if ut else None
2106
+ ramp_down = ut.params.get("ramp down (p.u. per min)") if ut else None
2107
+ if (ramp_up and ramp_up > 0) or (ramp_down and ramp_down > 0):
2108
+ unit_name = _get_unit_name(unit)
2109
+ rel = (unit_name, unit.output_node)
2110
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2111
+ if enabled:
2112
+ _add_param(db, "unit__outputNode", rel,
2113
+ "ramp_method", "ramp_limit", alt_name, counters)
2114
+ else:
2115
+ _add_param(db, "unit__outputNode", rel,
2116
+ "ramp_method", "no_ramp", alt_name, counters)
2117
+
2118
+ elif "use non synchronous" in param or "use_non_synchronous" in ov.param_name.lower():
2119
+ enabled = float(value) == 1 if isinstance(value, (int, float)) else False
2120
+ for ng in data.node_groups:
2121
+ if ng.non_synchronous_share is not None and ng.non_synchronous_share > 0:
2122
+ if _entity_exists(existing_entities, "group", (ng.name,)):
2123
+ if enabled:
2124
+ _add_param(db, "group", (ng.name,), "has_non_synchronous",
2125
+ "yes", alt_name, counters)
2126
+ _add_param(db, "group", (ng.name,), "non_synchronous_limit",
2127
+ ng.non_synchronous_share, alt_name, counters)
2128
+ else:
2129
+ _add_param(db, "group", (ng.name,), "has_non_synchronous",
2130
+ "no", alt_name, counters)
2131
+
2132
+ elif "use inertia limit" in param or "use_inertia_limit" in ov.param_name.lower():
2133
+ enabled = float(value) == 1 if isinstance(value, (int, float)) else False
2134
+ for ng in data.node_groups:
2135
+ if ng.inertia_limit_mws is not None and ng.inertia_limit_mws > 0:
2136
+ if _entity_exists(existing_entities, "group", (ng.name,)):
2137
+ if enabled:
2138
+ _add_param(db, "group", (ng.name,), "has_inertia",
2139
+ "yes", alt_name, counters)
2140
+ _add_param(db, "group", (ng.name,), "inertia_limit",
2141
+ ng.inertia_limit_mws, alt_name, counters)
2142
+ else:
2143
+ _add_param(db, "group", (ng.name,), "has_inertia",
2144
+ "no", alt_name, counters)
2145
+
2146
+ elif "mode invest" in param or "mode dispatch" in param:
2147
+ # Handled separately in _apply_mode_overrides
2148
+ pass
2149
+
2150
+ else:
2151
+ logger.warning("Unknown master sensitivity param: '%s'", ov.param_name)
2152
+
2153
+
2154
+ def _apply_mode_overrides(
2155
+ effective_master: dict[str, float],
2156
+ db: DatabaseMapping,
2157
+ alt_name: str,
2158
+ counters: _Counters,
2159
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2160
+ ) -> None:
2161
+ """Apply mode_invest / mode_dispatch overrides by computing effective mode."""
2162
+ mode_invest = False
2163
+ mode_dispatch = False
2164
+ for key, val in effective_master.items():
2165
+ key_lower = key.lower().replace("_", " ")
2166
+ if "mode invest" in key_lower and val == 1:
2167
+ mode_invest = True
2168
+ if "mode dispatch" in key_lower and val == 1:
2169
+ mode_dispatch = True
2170
+
2171
+ if mode_invest and mode_dispatch:
2172
+ model_solves = Array(["invest"])
2173
+ if _entity_exists(existing_entities, "model", ("flexTool",)):
2174
+ _add_param(db, "model", ("flexTool",), "solves", model_solves, alt_name, counters)
2175
+ if _entity_exists(existing_entities, "solve", ("invest",)):
2176
+ _add_param(db, "solve", ("invest",), "contains_solves", "dispatch",
2177
+ alt_name, counters)
2178
+ elif mode_invest:
2179
+ model_solves = Array(["invest"])
2180
+ if _entity_exists(existing_entities, "model", ("flexTool",)):
2181
+ _add_param(db, "model", ("flexTool",), "solves", model_solves, alt_name, counters)
2182
+ elif mode_dispatch:
2183
+ model_solves = Array(["dispatch"])
2184
+ if _entity_exists(existing_entities, "model", ("flexTool",)):
2185
+ _add_param(db, "model", ("flexTool",), "solves", model_solves, alt_name, counters)
2186
+
2187
+
2188
+ def _apply_node_group_override(
2189
+ ov: SensitivityOverride,
2190
+ db: DatabaseMapping,
2191
+ alt_name: str,
2192
+ counters: _Counters,
2193
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2194
+ ) -> None:
2195
+ """Apply a nodeGroup-section sensitivity override."""
2196
+ group_name = ov.entity_ids.get("nodeGroup")
2197
+ if not group_name:
2198
+ logger.warning("nodeGroup override missing 'nodeGroup' identifier; skipping.")
2199
+ return
2200
+
2201
+ if not _entity_exists(existing_entities, "group", (group_name,)):
2202
+ logger.warning("Group '%s' not found in DB; skipping nodeGroup override.", group_name)
2203
+ return
2204
+
2205
+ param_lower = ov.param_name.lower()
2206
+ value = ov.value
2207
+
2208
+ if "capacity margin" in param_lower:
2209
+ _add_param(db, "group", (group_name,), "capacity_margin", value, alt_name, counters)
2210
+ _add_param(db, "group", (group_name,), "has_capacity_margin", "yes", alt_name, counters)
2211
+ elif "non synchronous" in param_lower:
2212
+ _add_param(db, "group", (group_name,), "non_synchronous_limit", value, alt_name, counters)
2213
+ _add_param(db, "group", (group_name,), "has_non_synchronous", "yes", alt_name, counters)
2214
+ elif "inertia limit" in param_lower:
2215
+ _add_param(db, "group", (group_name,), "inertia_limit", value, alt_name, counters)
2216
+ _add_param(db, "group", (group_name,), "has_inertia", "yes", alt_name, counters)
2217
+ elif "use ts_reserve" in param_lower or "use dynamic reserve" in param_lower:
2218
+ # Need both flags to determine reserve method; just set the one we have
2219
+ # The reserve_method logic needs both flags, so we compute from the override
2220
+ use_ts = None
2221
+ use_dyn = None
2222
+ if "ts_reserve" in param_lower:
2223
+ use_ts = float(value) if isinstance(value, (int, float)) else None
2224
+ if "dynamic reserve" in param_lower:
2225
+ use_dyn = float(value) if isinstance(value, (int, float)) else None
2226
+ reserve_method = _get_reserve_method(use_ts, use_dyn)
2227
+ if reserve_method is not None:
2228
+ rel = ("primary", "up", group_name)
2229
+ if _entity_exists(existing_entities, "reserve__upDown__group", rel):
2230
+ _add_param(db, "reserve__upDown__group", rel,
2231
+ "reserve_method", reserve_method, alt_name, counters)
2232
+ else:
2233
+ logger.warning("Unknown nodeGroup sensitivity param: '%s'", ov.param_name)
2234
+
2235
+
2236
+ def _apply_unit_type_override(
2237
+ ov: SensitivityOverride,
2238
+ data: OldFlexToolData,
2239
+ db: DatabaseMapping,
2240
+ alt_name: str,
2241
+ counters: _Counters,
2242
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2243
+ ) -> None:
2244
+ """Apply a unit_type-section override to ALL units of that type."""
2245
+ type_name = ov.entity_ids.get("unit type")
2246
+ if not type_name:
2247
+ logger.warning("unit_type override missing 'unit type' identifier; skipping.")
2248
+ return
2249
+
2250
+ param_lower = ov.param_name.lower()
2251
+ value = ov.value
2252
+
2253
+ # Find all units with this unit_type
2254
+ matching_units = [u for u in data.units if u.unit_type == type_name and u.output_node]
2255
+
2256
+ if not matching_units:
2257
+ logger.warning("No units found for unit_type '%s'; skipping override.", type_name)
2258
+ return
2259
+
2260
+ for unit in matching_units:
2261
+ unit_name = _get_unit_name(unit)
2262
+ is_storage = _needs_charger_discharger(unit, data)
2263
+
2264
+ if is_storage:
2265
+ charger = f"{unit_name}_charger"
2266
+ discharger = f"{unit_name}_discharger"
2267
+ if not _entity_exists(existing_entities, "unit", (discharger,)):
2268
+ logger.warning("Unit '%s' (discharger) not found in DB; skipping.", discharger)
2269
+ continue
2270
+ else:
2271
+ if not _entity_exists(existing_entities, "unit", (unit_name,)):
2272
+ logger.warning("Unit '%s' not found in DB; skipping.", unit_name)
2273
+ continue
2274
+
2275
+ if "efficiency" == param_lower:
2276
+ if is_storage:
2277
+ _add_param(db, "unit", (discharger,), "efficiency", value, alt_name, counters)
2278
+ else:
2279
+ _add_param(db, "unit", (unit_name,), "efficiency", value, alt_name, counters)
2280
+ elif "eff charge" in param_lower:
2281
+ if is_storage:
2282
+ _add_param(db, "unit", (charger,), "efficiency", value, alt_name, counters)
2283
+ elif "eff at min load" in param_lower:
2284
+ target = discharger if is_storage else unit_name
2285
+ _add_param(db, "unit", (target,), "efficiency_at_min_load", value, alt_name, counters)
2286
+ elif "min load" == param_lower:
2287
+ target = discharger if is_storage else unit_name
2288
+ _add_param(db, "unit", (target,), "min_load", value, alt_name, counters)
2289
+ elif "availability" == param_lower:
2290
+ if is_storage:
2291
+ _add_param(db, "unit", (charger,), "availability", value, alt_name, counters)
2292
+ _add_param(db, "unit", (discharger,), "availability", value, alt_name, counters)
2293
+ else:
2294
+ _add_param(db, "unit", (unit_name,), "availability", value, alt_name, counters)
2295
+ elif "inv.cost/kw" in param_lower or "inv cost" in param_lower:
2296
+ target = discharger if is_storage else unit_name
2297
+ _add_param(db, "unit", (target,), "invest_cost", value, alt_name, counters)
2298
+ elif "fixed cost" in param_lower:
2299
+ target = discharger if is_storage else unit_name
2300
+ _add_param(db, "unit", (target,), "fixed_cost", value, alt_name, counters)
2301
+ elif "lifetime" == param_lower:
2302
+ target = discharger if is_storage else unit_name
2303
+ _add_param(db, "unit", (target,), "lifetime", value, alt_name, counters)
2304
+ elif "interest" == param_lower:
2305
+ target = discharger if is_storage else unit_name
2306
+ _add_param(db, "unit", (target,), "discount_rate", value, alt_name, counters)
2307
+ elif "startup cost" in param_lower:
2308
+ target = discharger if is_storage else unit_name
2309
+ _add_param(db, "unit", (target,), "startup_cost", value, alt_name, counters)
2310
+ elif "o&m cost" in param_lower:
2311
+ if is_storage:
2312
+ rel = (discharger, unit.output_node)
2313
+ else:
2314
+ rel = (unit_name, unit.output_node)
2315
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2316
+ _add_param(db, "unit__outputNode", rel,
2317
+ "other_operational_cost", value, alt_name, counters)
2318
+ elif "ramp up" in param_lower:
2319
+ if is_storage:
2320
+ for sub, node in ((charger, f"{unit_name}_storage"), (discharger, unit.output_node)):
2321
+ rel = (sub, node)
2322
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2323
+ _add_param(db, "unit__outputNode", rel,
2324
+ "ramp_speed_up", value, alt_name, counters)
2325
+ else:
2326
+ rel = (unit_name, unit.output_node)
2327
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2328
+ _add_param(db, "unit__outputNode", rel,
2329
+ "ramp_speed_up", value, alt_name, counters)
2330
+ elif "ramp down" in param_lower:
2331
+ if is_storage:
2332
+ for sub, node in ((charger, f"{unit_name}_storage"), (discharger, unit.output_node)):
2333
+ rel = (sub, node)
2334
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2335
+ _add_param(db, "unit__outputNode", rel,
2336
+ "ramp_speed_down", value, alt_name, counters)
2337
+ else:
2338
+ rel = (unit_name, unit.output_node)
2339
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2340
+ _add_param(db, "unit__outputNode", rel,
2341
+ "ramp_speed_down", value, alt_name, counters)
2342
+ elif "self discharge" in param_lower:
2343
+ storage_node = f"{unit_name}_storage"
2344
+ if _entity_exists(existing_entities, "node", (storage_node,)):
2345
+ _add_param(db, "node", (storage_node,), "self_discharge_loss",
2346
+ value, alt_name, counters)
2347
+ elif "non synchronous" in param_lower:
2348
+ is_ns = "yes" if (isinstance(value, (int, float)) and float(value) == 1) else "no"
2349
+ if is_storage:
2350
+ for sub, node in ((charger, unit.output_node), (discharger, unit.output_node)):
2351
+ rel = (sub, node)
2352
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2353
+ _add_param(db, "unit__outputNode", rel,
2354
+ "is_non_synchronous", is_ns, alt_name, counters)
2355
+ else:
2356
+ rel = (unit_name, unit.output_node)
2357
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2358
+ _add_param(db, "unit__outputNode", rel,
2359
+ "is_non_synchronous", is_ns, alt_name, counters)
2360
+ elif "inertia constant" in param_lower:
2361
+ if is_storage:
2362
+ for sub, node in ((charger, unit.output_node), (discharger, unit.output_node)):
2363
+ rel = (sub, node)
2364
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2365
+ _add_param(db, "unit__outputNode", rel,
2366
+ "inertia_constant", value, alt_name, counters)
2367
+ else:
2368
+ rel = (unit_name, unit.output_node)
2369
+ if _entity_exists(existing_entities, "unit__outputNode", rel):
2370
+ _add_param(db, "unit__outputNode", rel,
2371
+ "inertia_constant", value, alt_name, counters)
2372
+ else:
2373
+ logger.warning(
2374
+ "Unknown unit_type sensitivity param: '%s' for type '%s'",
2375
+ ov.param_name, type_name,
2376
+ )
2377
+
2378
+
2379
+ def _apply_fuel_override(
2380
+ ov: SensitivityOverride,
2381
+ db: DatabaseMapping,
2382
+ alt_name: str,
2383
+ counters: _Counters,
2384
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2385
+ ) -> None:
2386
+ """Apply a fuel-section sensitivity override."""
2387
+ fuel_name = ov.entity_ids.get("fuel")
2388
+ if not fuel_name:
2389
+ logger.warning("fuel override missing 'fuel' identifier; skipping.")
2390
+ return
2391
+
2392
+ if not _entity_exists(existing_entities, "commodity", (fuel_name,)):
2393
+ logger.warning("Commodity '%s' not found in DB; skipping fuel override.", fuel_name)
2394
+ return
2395
+
2396
+ param_lower = ov.param_name.lower()
2397
+ value = ov.value
2398
+
2399
+ if "price" in param_lower:
2400
+ _add_param(db, "commodity", (fuel_name,), "price", value, alt_name, counters)
2401
+ elif "co2" in param_lower:
2402
+ _add_param(db, "commodity", (fuel_name,), "co2_content", value, alt_name, counters)
2403
+ else:
2404
+ logger.warning("Unknown fuel sensitivity param: '%s'", ov.param_name)
2405
+
2406
+
2407
+ def _apply_unit_group_override(
2408
+ ov: SensitivityOverride,
2409
+ db: DatabaseMapping,
2410
+ alt_name: str,
2411
+ counters: _Counters,
2412
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2413
+ ) -> None:
2414
+ """Apply a unitGroup-section sensitivity override."""
2415
+ group_name = ov.entity_ids.get("unitGroup")
2416
+ if not group_name:
2417
+ logger.warning("unitGroup override missing 'unitGroup' identifier; skipping.")
2418
+ return
2419
+
2420
+ if not _entity_exists(existing_entities, "group", (group_name,)):
2421
+ logger.warning("Group '%s' not found in DB; skipping unitGroup override.", group_name)
2422
+ return
2423
+
2424
+ param_lower = ov.param_name.lower()
2425
+ value = ov.value
2426
+
2427
+ if "max invest" in param_lower:
2428
+ _add_param(db, "group", (group_name,), "invest_max_total", value, alt_name, counters)
2429
+ if isinstance(value, (int, float)) and float(value) > 0:
2430
+ _add_param(db, "group", (group_name,), "invest_method", "invest_total",
2431
+ alt_name, counters)
2432
+ elif "min invest" in param_lower:
2433
+ _add_param(db, "group", (group_name,), "invest_min_total", value, alt_name, counters)
2434
+ else:
2435
+ logger.warning("Unknown unitGroup sensitivity param: '%s'", ov.param_name)
2436
+
2437
+
2438
+
2439
+ def _apply_units_override(
2440
+ ov: SensitivityOverride,
2441
+ data: OldFlexToolData,
2442
+ db: DatabaseMapping,
2443
+ alt_name: str,
2444
+ counters: _Counters,
2445
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2446
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]],
2447
+ forced_invest: dict[str, set[str]] | None = None,
2448
+ ) -> None:
2449
+ """Apply a units-section sensitivity override."""
2450
+ unit_type = ov.entity_ids.get("unittype")
2451
+ output_node = ov.entity_ids.get("output node")
2452
+
2453
+ if not unit_type or not output_node:
2454
+ logger.warning(
2455
+ "units override missing 'unittype' or 'output node'; skipping. IDs: %s",
2456
+ ov.entity_ids,
2457
+ )
2458
+ return
2459
+
2460
+ unit_name = f"{unit_type}_{output_node}"
2461
+
2462
+ # Identify the base unit and whether it's a pure storage pattern
2463
+ base_unit = next(
2464
+ (u for u in data.units
2465
+ if u.unit_type == unit_type and u.output_node == output_node),
2466
+ None,
2467
+ )
2468
+ is_storage = base_unit is not None and _needs_charger_discharger(base_unit, data)
2469
+
2470
+ if is_storage:
2471
+ charger = f"{unit_name}_charger"
2472
+ discharger = f"{unit_name}_discharger"
2473
+ storage_node = f"{unit_name}_storage"
2474
+ if not _entity_exists(existing_entities, "unit", (discharger,)):
2475
+ logger.warning("Unit '%s' (discharger) not found in DB; skipping.", discharger)
2476
+ return
2477
+ else:
2478
+ if not _entity_exists(existing_entities, "unit", (unit_name,)):
2479
+ logger.warning("Unit '%s' not found in DB; skipping units override.", unit_name)
2480
+ return
2481
+
2482
+ param_lower = ov.param_name.lower()
2483
+ value = ov.value
2484
+
2485
+ if "capacity (mw)" == param_lower or param_lower == "capacity":
2486
+ if is_storage:
2487
+ _add_param(db, "unit", (charger,), "existing", value, alt_name, counters)
2488
+ _add_param(db, "unit", (discharger,), "existing", value, alt_name, counters)
2489
+ else:
2490
+ _add_param(db, "unit", (unit_name,), "existing", value, alt_name, counters)
2491
+ elif "invested capacity" in param_lower:
2492
+ if is_storage:
2493
+ for target in (charger, discharger):
2494
+ _add_param(db, "unit", (target,), "invest_max_total", value, alt_name, counters)
2495
+ _add_param(db, "unit", (target,), "invest_min_total", value, alt_name, counters)
2496
+ _add_param(db, "unit", (target,), "invest_method", "invest_total",
2497
+ alt_name, counters)
2498
+ if forced_invest is not None:
2499
+ forced_invest.setdefault(unit_name, set()).add("mw")
2500
+ else:
2501
+ _add_param(db, "unit", (unit_name,), "invest_max_total", value, alt_name, counters)
2502
+ _add_param(db, "unit", (unit_name,), "invest_min_total", value, alt_name, counters)
2503
+ _add_param(db, "unit", (unit_name,), "invest_method", "invest_total",
2504
+ alt_name, counters)
2505
+ elif "max invest (mw)" in param_lower and "mwh" not in param_lower:
2506
+ if is_storage:
2507
+ for target in (charger, discharger):
2508
+ _add_param(db, "unit", (target,), "invest_max_total", value, alt_name, counters)
2509
+ if isinstance(value, (int, float)) and float(value) > 0:
2510
+ _add_param(db, "unit", (target,), "invest_method", "invest_total",
2511
+ alt_name, counters)
2512
+ else:
2513
+ _add_param(db, "unit", (unit_name,), "invest_max_total", value, alt_name, counters)
2514
+ if isinstance(value, (int, float)) and float(value) > 0:
2515
+ _add_param(db, "unit", (unit_name,), "invest_method", "invest_total",
2516
+ alt_name, counters)
2517
+ elif "invested storage" in param_lower:
2518
+ storage_target = storage_node if is_storage else _get_unit_node_name(base_unit)
2519
+ if _entity_exists(existing_entities, "node", (storage_target,)):
2520
+ _add_param(db, "node", (storage_target,), "invest_max_total", value,
2521
+ alt_name, counters)
2522
+ _add_param(db, "node", (storage_target,), "invest_min_total", value,
2523
+ alt_name, counters)
2524
+ _add_param(db, "node", (storage_target,), "invest_method", "invest_total",
2525
+ alt_name, counters)
2526
+ _add_param(db, "node", (storage_target,), "node_type", "storage",
2527
+ alt_name, counters)
2528
+ if forced_invest is not None:
2529
+ forced_invest.setdefault(unit_name, set()).add("mwh")
2530
+ elif "max invest (mwh)" in param_lower:
2531
+ storage_target = storage_node if is_storage else _get_unit_node_name(base_unit)
2532
+ if _entity_exists(existing_entities, "node", (storage_target,)):
2533
+ _add_param(db, "node", (storage_target,), "node_type", "storage",
2534
+ alt_name, counters)
2535
+ _add_param(db, "node", (storage_target,), "invest_max_total", value,
2536
+ alt_name, counters)
2537
+ _add_param(db, "node", (storage_target,), "invest_method", "invest_total",
2538
+ alt_name, counters)
2539
+ elif "storage (mwh)" in param_lower or (
2540
+ "storage" in param_lower and "invest" not in param_lower and "start" not in param_lower and "finish" not in param_lower
2541
+ ):
2542
+ storage_target = storage_node if is_storage else _get_unit_node_name(base_unit)
2543
+ if _entity_exists(existing_entities, "node", (storage_target,)):
2544
+ _add_param(db, "node", (storage_target,), "existing", value,
2545
+ alt_name, counters)
2546
+ if float(value) > 0:
2547
+ _add_param(db, "node", (storage_target,), "node_type", "storage",
2548
+ alt_name, counters)
2549
+ else:
2550
+ # Existing capacity == 0 — downgrade from storage to a
2551
+ # plain balance node (drops the state variable).
2552
+ _add_param(db, "node", (storage_target,), "node_type", "balance",
2553
+ alt_name, counters)
2554
+ elif "inv.cost/kw" in param_lower or "inv cost" in param_lower:
2555
+ target = discharger if is_storage else unit_name
2556
+ _add_param(db, "unit", (target,), "invest_cost", value, alt_name, counters)
2557
+ elif "efficiency" == param_lower:
2558
+ target = discharger if is_storage else unit_name
2559
+ _add_param(db, "unit", (target,), "efficiency", value, alt_name, counters)
2560
+ elif "eff charge" in param_lower:
2561
+ if is_storage:
2562
+ _add_param(db, "unit", (charger,), "efficiency", value, alt_name, counters)
2563
+ elif "min load" in param_lower:
2564
+ target = discharger if is_storage else unit_name
2565
+ _add_param(db, "unit", (target,), "min_load", value, alt_name, counters)
2566
+ elif "inflow multiplier" in param_lower:
2567
+ # Re-read the original inflow profile, apply the new multiplier,
2568
+ # and write to the unit's inflow node under this alternative
2569
+ multiplier = float(value) if value else 0.0
2570
+ if base_unit and base_unit.inflow_profile:
2571
+ inflow_ts = _find_inflow_profile(data, base_unit.inflow_profile)
2572
+ if inflow_ts and inflow_ts.data:
2573
+ scaled = {k: v * multiplier for k, v in inflow_ts.data.items()}
2574
+ inflow_map = _make_time_map(scaled)
2575
+ inflow_node = _get_unit_node_name(base_unit)
2576
+ if _entity_exists(existing_entities, "node", (inflow_node,)):
2577
+ _add_param(db, "node", (inflow_node,), "inflow",
2578
+ inflow_map, alt_name, counters)
2579
+ else:
2580
+ logger.warning(
2581
+ "Inflow node '%s' not found; cannot apply inflow multiplier.",
2582
+ inflow_node,
2583
+ )
2584
+ else:
2585
+ logger.warning(
2586
+ "Inflow profile '%s' not found for unit '%s'; cannot apply multiplier.",
2587
+ base_unit.inflow_profile, unit_name,
2588
+ )
2589
+ else:
2590
+ logger.warning(
2591
+ "No inflow profile for unit '%s'; cannot apply inflow multiplier.",
2592
+ unit_name,
2593
+ )
2594
+ else:
2595
+ logger.warning("Unknown units sensitivity param: '%s' for unit '%s'",
2596
+ ov.param_name, unit_name)
2597
+
2598
+
2599
+ def _apply_grid_node_override(
2600
+ ov: SensitivityOverride,
2601
+ data: OldFlexToolData,
2602
+ db: DatabaseMapping,
2603
+ alt_name: str,
2604
+ counters: _Counters,
2605
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2606
+ ) -> None:
2607
+ """Apply a gridNode-section sensitivity override."""
2608
+ node_name = ov.entity_ids.get("node")
2609
+ if not node_name:
2610
+ logger.warning("gridNode override missing 'node' identifier; skipping.")
2611
+ return
2612
+
2613
+ if not _entity_exists(existing_entities, "node", (node_name,)):
2614
+ logger.warning("Node '%s' not found in DB; skipping gridNode override.", node_name)
2615
+ return
2616
+
2617
+ param_lower = ov.param_name.lower()
2618
+ value = ov.value
2619
+
2620
+ if "demand" in param_lower:
2621
+ _add_param(db, "node", (node_name,), "annual_flow", value, alt_name, counters)
2622
+ elif "capacity margin" in param_lower:
2623
+ # This applies to the node's group, find the group
2624
+ for gn in data.grid_nodes:
2625
+ if gn.node == node_name:
2626
+ for ng_name in gn.node_groups:
2627
+ if _entity_exists(existing_entities, "group", (ng_name,)):
2628
+ _add_param(db, "group", (ng_name,), "capacity_margin",
2629
+ value, alt_name, counters)
2630
+ _add_param(db, "group", (ng_name,), "has_capacity_margin",
2631
+ "yes", alt_name, counters)
2632
+ elif "non synchronous" in param_lower:
2633
+ for gn in data.grid_nodes:
2634
+ if gn.node == node_name:
2635
+ for ng_name in gn.node_groups:
2636
+ if _entity_exists(existing_entities, "group", (ng_name,)):
2637
+ _add_param(db, "group", (ng_name,), "non_synchronous_limit",
2638
+ value, alt_name, counters)
2639
+ _add_param(db, "group", (ng_name,), "has_non_synchronous",
2640
+ "yes", alt_name, counters)
2641
+ elif "use ts_reserve" in param_lower or "use dynamic reserve" in param_lower:
2642
+ use_ts = None
2643
+ use_dyn = None
2644
+ if "ts_reserve" in param_lower:
2645
+ use_ts = float(value) if isinstance(value, (int, float)) else None
2646
+ if "dynamic reserve" in param_lower:
2647
+ use_dyn = float(value) if isinstance(value, (int, float)) else None
2648
+ reserve_method = _get_reserve_method(use_ts, use_dyn)
2649
+ if reserve_method is not None:
2650
+ # Find the reserve group for this node
2651
+ for gn in data.grid_nodes:
2652
+ if gn.node == node_name:
2653
+ for ng_name in gn.node_groups:
2654
+ rel = ("primary", "up", ng_name)
2655
+ if _entity_exists(existing_entities, "reserve__upDown__group", rel):
2656
+ _add_param(db, "reserve__upDown__group", rel,
2657
+ "reserve_method", reserve_method, alt_name, counters)
2658
+ else:
2659
+ logger.warning("Unknown gridNode sensitivity param: '%s'", ov.param_name)
2660
+
2661
+
2662
+ def _apply_node_node_override(
2663
+ ov: SensitivityOverride,
2664
+ db: DatabaseMapping,
2665
+ alt_name: str,
2666
+ counters: _Counters,
2667
+ existing_entities: set[tuple[str, tuple[str, ...]]],
2668
+ ) -> None:
2669
+ """Apply a nodeNode-section sensitivity override."""
2670
+ node1 = ov.entity_ids.get("node1")
2671
+ node2 = ov.entity_ids.get("node2")
2672
+
2673
+ if not node1 or not node2:
2674
+ logger.warning("nodeNode override missing 'node1' or 'node2'; skipping.")
2675
+ return
2676
+
2677
+ conn_name = f"{node1}_{node2}"
2678
+ if not _entity_exists(existing_entities, "connection", (conn_name,)):
2679
+ logger.warning("Connection '%s' not found in DB; skipping nodeNode override.", conn_name)
2680
+ return
2681
+
2682
+ param_lower = ov.param_name.lower()
2683
+ value = ov.value
2684
+
2685
+ if "invested capacity" in param_lower:
2686
+ _add_param(db, "connection", (conn_name,), "invest_max_total", value, alt_name, counters)
2687
+ _add_param(db, "connection", (conn_name,), "invest_min_total", value, alt_name, counters)
2688
+ _add_param(db, "connection", (conn_name,), "invest_method", "invest_total",
2689
+ alt_name, counters)
2690
+ elif "cap.rightward" in param_lower or "cap.leftward" in param_lower:
2691
+ # Use the value as existing capacity
2692
+ _add_param(db, "connection", (conn_name,), "existing", value, alt_name, counters)
2693
+ elif "max invest" in param_lower:
2694
+ _add_param(db, "connection", (conn_name,), "invest_max_total", value, alt_name, counters)
2695
+ if isinstance(value, (int, float)) and float(value) > 0:
2696
+ _add_param(db, "connection", (conn_name,), "invest_method", "invest_total",
2697
+ alt_name, counters)
2698
+ elif "loss" == param_lower:
2699
+ eff = 1.0 - float(value) if isinstance(value, (int, float)) else 1.0
2700
+ _add_param(db, "connection", (conn_name,), "efficiency", eff, alt_name, counters)
2701
+ elif "inv.cost/kw" in param_lower or "inv cost" in param_lower:
2702
+ _add_param(db, "connection", (conn_name,), "invest_cost", value, alt_name, counters)
2703
+ elif "lifetime" == param_lower:
2704
+ _add_param(db, "connection", (conn_name,), "lifetime", value, alt_name, counters)
2705
+ elif "interest" == param_lower:
2706
+ _add_param(db, "connection", (conn_name,), "discount_rate", value, alt_name, counters)
2707
+ else:
2708
+ logger.warning("Unknown nodeNode sensitivity param: '%s' for connection '%s'",
2709
+ ov.param_name, conn_name)
2710
+
2711
+
2712
+ def write_sensitivities_to_db(
2713
+ sensitivities: dict[str, list[SensitivityOverride]],
2714
+ data: OldFlexToolData,
2715
+ db_url: str,
2716
+ base_alternative: str = "base",
2717
+ ) -> None:
2718
+ """Write sensitivity alternatives and scenarios on top of an already-imported DB.
2719
+
2720
+ This does NOT purge the database. It layers sensitivity alternatives on top
2721
+ of the base import.
2722
+
2723
+ Args:
2724
+ sensitivities: Dict mapping scenario_name -> list of SensitivityOverride.
2725
+ data: The base OldFlexToolData (needed for unit_type->unit mapping, etc.).
2726
+ db_url: Spine database URL.
2727
+ base_alternative: Name of the base alternative already in the DB.
2728
+ """
2729
+ if not sensitivities:
2730
+ logger.info("No sensitivities to write.")
2731
+ return
2732
+
2733
+ logger.info(
2734
+ "Writing %d sensitivity scenarios to: %s",
2735
+ len(sensitivities), db_url,
2736
+ )
2737
+
2738
+ with DatabaseMapping(db_url, create=False, upgrade=True) as db:
2739
+ counters = _Counters()
2740
+ entity_alts_added: set[tuple[str, tuple[str, ...], str]] = set()
2741
+
2742
+ # Build entity existence lookup from DB
2743
+ existing_entities = _build_existing_entity_set(db)
2744
+
2745
+ # Ensure base alternative exists
2746
+ try:
2747
+ db.add_alternative(name=base_alternative)
2748
+ except SpineDBAPIError:
2749
+ pass
2750
+
2751
+ # Create a "base" scenario with just the base alternative
2752
+ try:
2753
+ db.add_scenario_item(name="base")
2754
+ except SpineDBAPIError:
2755
+ pass
2756
+ try:
2757
+ db.add_scenario_alternative_item(
2758
+ scenario_name="base",
2759
+ alternative_name=base_alternative,
2760
+ rank=1,
2761
+ )
2762
+ except SpineDBAPIError:
2763
+ pass
2764
+
2765
+ for scenario_name, overrides in sensitivities.items():
2766
+ logger.info("Processing sensitivity scenario: '%s' (%d overrides)",
2767
+ scenario_name, len(overrides))
2768
+
2769
+ # 1. Create alternative for this scenario
2770
+ try:
2771
+ db.add_alternative(name=scenario_name)
2772
+ except SpineDBAPIError:
2773
+ pass
2774
+
2775
+ # 2. Compute effective master params (base + all master overrides)
2776
+ effective_master = _get_effective_master_params(data, overrides)
2777
+
2778
+ # 3. Check if mode overrides are present
2779
+ has_mode_override = any(
2780
+ ov.section == "master"
2781
+ and ("mode invest" in ov.param_name.lower().replace("_", " ")
2782
+ or "mode dispatch" in ov.param_name.lower().replace("_", " "))
2783
+ for ov in overrides
2784
+ )
2785
+
2786
+ # Track forced investments per unit for constraint deactivation
2787
+ # Keys: unit_name, values: set of "mw" and/or "mwh"
2788
+ forced_invest: dict[str, set[str]] = {}
2789
+
2790
+ # 4. Process each override
2791
+ for ov in overrides:
2792
+ if ov.section == "master":
2793
+ _apply_master_override(
2794
+ ov, data, effective_master, db, scenario_name,
2795
+ counters, existing_entities,
2796
+ )
2797
+ elif ov.section == "nodeGroup":
2798
+ _apply_node_group_override(
2799
+ ov, db, scenario_name, counters, existing_entities,
2800
+ )
2801
+ elif ov.section == "gridNode":
2802
+ _apply_grid_node_override(
2803
+ ov, data, db, scenario_name, counters, existing_entities,
2804
+ )
2805
+ elif ov.section == "unit_type":
2806
+ _apply_unit_type_override(
2807
+ ov, data, db, scenario_name, counters, existing_entities,
2808
+ )
2809
+ elif ov.section == "fuel":
2810
+ _apply_fuel_override(
2811
+ ov, db, scenario_name, counters, existing_entities,
2812
+ )
2813
+ elif ov.section == "unitGroup":
2814
+ _apply_unit_group_override(
2815
+ ov, db, scenario_name, counters, existing_entities,
2816
+ )
2817
+ elif ov.section == "units":
2818
+ _apply_units_override(
2819
+ ov, data, db, scenario_name, counters, existing_entities,
2820
+ entity_alts_added, forced_invest,
2821
+ )
2822
+ elif ov.section == "nodeNode":
2823
+ _apply_node_node_override(
2824
+ ov, db, scenario_name, counters, existing_entities,
2825
+ )
2826
+ else:
2827
+ logger.warning("Unknown sensitivity section: '%s'", ov.section)
2828
+
2829
+ # 4b. Handle storage_link constraint activation/deactivation.
2830
+ # The charger_link (charger ↔ discharger) always stays active.
2831
+ # The storage_link (discharger ↔ storage node kW/kWh ratio) is
2832
+ # deactivated when both MW and MWh are forced, to avoid rounding.
2833
+ for unit in data.units:
2834
+ if not _needs_charger_discharger(unit, data):
2835
+ continue
2836
+ unit_name = _get_unit_name(unit)
2837
+ ut = _get_unit_type(data, unit)
2838
+ kw_kwh_ratio = ut.params.get("fixed kw/kwh ratio") if ut else None
2839
+ if not (kw_kwh_ratio is not None and kw_kwh_ratio > 0):
2840
+ continue # No storage_link to manage
2841
+
2842
+ # Base forced state
2843
+ base_forced_mw = (
2844
+ unit.invested_capacity_mw is not None
2845
+ and unit.invested_capacity_mw > 0
2846
+ )
2847
+ base_forced_mwh = (
2848
+ unit.invested_storage_mwh is not None
2849
+ and unit.invested_storage_mwh > 0
2850
+ )
2851
+ base_both_forced = base_forced_mw and base_forced_mwh
2852
+
2853
+ # Effective forced state (base + sensitivity overrides)
2854
+ sens_types = forced_invest.get(unit_name, set())
2855
+ eff_forced_mw = "mw" in sens_types or (
2856
+ base_forced_mw and "mw" not in sens_types
2857
+ )
2858
+ eff_forced_mwh = "mwh" in sens_types or (
2859
+ base_forced_mwh and "mwh" not in sens_types
2860
+ )
2861
+ eff_both_forced = eff_forced_mw and eff_forced_mwh
2862
+
2863
+ if eff_both_forced == base_both_forced:
2864
+ continue # No change
2865
+
2866
+ charger_link = f"{unit_name}_charger_link"
2867
+ storage_link = f"{unit_name}_storage_link"
2868
+ discharger = f"{unit_name}_discharger"
2869
+ storage_node = f"{unit_name}_storage"
2870
+
2871
+ if eff_both_forced and not base_both_forced:
2872
+ # Deactivate storage_link, rewrite discharger coeff without it
2873
+ if _entity_exists(existing_entities, "constraint", (storage_link,)):
2874
+ try:
2875
+ db.add_or_update_entity_alternative(
2876
+ entity_class_name="constraint",
2877
+ entity_byname=(storage_link,),
2878
+ alternative_name=scenario_name,
2879
+ active=False,
2880
+ )
2881
+ except SpineDBAPIError:
2882
+ pass
2883
+
2884
+ # Discharger: only charger_link (drop storage_link)
2885
+ _add_param(db, "unit", (discharger,),
2886
+ "constraint_invested_capacity_coeff",
2887
+ Map([charger_link], [1.0], index_name="constraint"),
2888
+ scenario_name, counters)
2889
+ # Storage node: null
2890
+ null_value, null_type = api.to_database(None)
2891
+ if _entity_exists(existing_entities, "node", (storage_node,)):
2892
+ try:
2893
+ db.add_parameter_value(
2894
+ entity_class_name="node",
2895
+ entity_byname=(storage_node,),
2896
+ parameter_definition_name="constraint_invested_capacity_coeff",
2897
+ alternative_name=scenario_name,
2898
+ value=null_value, type=null_type,
2899
+ )
2900
+ except SpineDBAPIError:
2901
+ pass
2902
+
2903
+ elif base_both_forced and not eff_both_forced:
2904
+ # Re-activate storage_link, write full coefficients
2905
+ if _entity_exists(existing_entities, "constraint", (storage_link,)):
2906
+ try:
2907
+ db.add_or_update_entity_alternative(
2908
+ entity_class_name="constraint",
2909
+ entity_byname=(storage_link,),
2910
+ alternative_name=scenario_name,
2911
+ active=True,
2912
+ )
2913
+ except SpineDBAPIError:
2914
+ pass
2915
+
2916
+ # Discharger: both charger_link + storage_link
2917
+ _add_param(db, "unit", (discharger,),
2918
+ "constraint_invested_capacity_coeff",
2919
+ Map([charger_link, storage_link],
2920
+ [1.0, kw_kwh_ratio],
2921
+ index_name="constraint"),
2922
+ scenario_name, counters)
2923
+ # Storage node coefficient
2924
+ _add_param(db, "node", (storage_node,),
2925
+ "constraint_invested_capacity_coeff",
2926
+ Map([storage_link], [-1.0], index_name="constraint"),
2927
+ scenario_name, counters)
2928
+
2929
+ # 5. Apply mode overrides if present
2930
+ if has_mode_override:
2931
+ _apply_mode_overrides(
2932
+ effective_master, db, scenario_name, counters, existing_entities,
2933
+ )
2934
+
2935
+ # 6. Create scenario with base + sensitivity alternatives
2936
+ try:
2937
+ db.add_scenario_item(name=scenario_name)
2938
+ except SpineDBAPIError:
2939
+ pass
2940
+ try:
2941
+ db.add_scenario_alternative_item(
2942
+ scenario_name=scenario_name,
2943
+ alternative_name=base_alternative,
2944
+ rank=1,
2945
+ )
2946
+ except SpineDBAPIError:
2947
+ pass
2948
+ try:
2949
+ db.add_scenario_alternative_item(
2950
+ scenario_name=scenario_name,
2951
+ alternative_name=scenario_name,
2952
+ rank=2,
2953
+ )
2954
+ except SpineDBAPIError:
2955
+ pass
2956
+
2957
+ logger.info("Created scenario '%s' with alternatives: [%s, %s]",
2958
+ scenario_name, base_alternative, scenario_name)
2959
+
2960
+ # Commit everything
2961
+ try:
2962
+ db.commit_session("Import sensitivity overrides")
2963
+ logger.info(
2964
+ "Successfully committed sensitivity data. Summary: %s",
2965
+ counters.summary(),
2966
+ )
2967
+ except NothingToCommit:
2968
+ logger.info("No new sensitivity data to commit.")
2969
+ except SpineDBAPIError as exc:
2970
+ raise RuntimeError(
2971
+ f"Failed to commit sensitivity data: {exc}"
2972
+ ) from exc
2973
+
2974
+
2975
+ # ---------------------------------------------------------------------------
2976
+ # Full import pipeline: frozen-template init → write → migrate
2977
+ # ---------------------------------------------------------------------------
2978
+ #
2979
+ # The FlexTool-2 importer hand-writes a *fixed* schema version — the one that
2980
+ # was current when it was written (v56; see ``OLD_FLEXTOOL_IMPORT_SCHEMA``,
2981
+ # whose ``model.version`` default is 56). It is deliberately NOT kept in step
2982
+ # with the live schema: the DB is initialised from the frozen template, the
2983
+ # importer writes its v56 data, and ``migrate_database`` then carries the
2984
+ # result forward to whatever the current ``FLEXTOOL_DB_VERSION`` is.
2985
+ #
2986
+ # This is what removes the maintenance burden — a future schema rename is
2987
+ # handled by its migration step (``db_migration.py``), not by editing this
2988
+ # importer. Initialising from the *live* schema instead (the old behaviour)
2989
+ # would silently drop every value whose name a later rename had changed,
2990
+ # because ``_add_param`` skips unknown parameters. The frozen template +
2991
+ # always-migrate keeps the importer correct without touching it again.
2992
+
2993
+ #: Frozen schema the importer targets. Snapshot of ``spinedb_schema.json``
2994
+ #: taken at DB version 56; do NOT update it to track the live schema — that is
2995
+ #: precisely what migration exists to do.
2996
+ OLD_FLEXTOOL_IMPORT_SCHEMA = "schemas/old_flextool_import_template.json"
2997
+ OLD_FLEXTOOL_IMPORT_SCHEMA_VERSION = 56
2998
+
2999
+
3000
+ def _sqlite_path_from_url(db_url: str) -> str | None:
3001
+ """Return the filesystem path of a ``sqlite:///`` URL, else ``None``.
3002
+
3003
+ ``sqlite:///rel.sqlite`` → ``rel.sqlite`` (relative); the absolute form
3004
+ ``sqlite:////abs/path.sqlite`` → ``/abs/path.sqlite`` (the 4th slash is
3005
+ the leading slash of the path).
3006
+ """
3007
+ prefix = "sqlite:///"
3008
+ if db_url.startswith(prefix):
3009
+ return db_url[len(prefix):]
3010
+ return None
3011
+
3012
+
3013
+ def _db_is_initialized(db_url: str) -> bool:
3014
+ """True if the DB already carries the FlexTool schema (``model.version``)."""
3015
+ try:
3016
+ with DatabaseMapping(db_url) as db:
3017
+ return db.get_parameter_definition_item(
3018
+ entity_class_name="model", name="version"
3019
+ ) is not None
3020
+ except Exception:
3021
+ return False
3022
+
3023
+
3024
+ def import_old_flextool_xlsm(
3025
+ xlsm_path: str,
3026
+ target_db_url: str,
3027
+ *,
3028
+ alternative_name: str = "base",
3029
+ purge: bool = True,
3030
+ migrate: bool = True,
3031
+ ) -> None:
3032
+ """Import an old-format FlexTool ``.xlsm`` into ``target_db_url``.
3033
+
3034
+ The complete, single-source pipeline used by both the CLI and the GUI:
3035
+
3036
+ 1. Initialise the target from the **frozen** v56 import template
3037
+ (``OLD_FLEXTOOL_IMPORT_SCHEMA``) unless it is already a FlexTool DB.
3038
+ 2. Parse the workbook and write its data (this importer targets v56).
3039
+ 3. ``migrate_database`` the result forward to the current
3040
+ ``FLEXTOOL_DB_VERSION`` — a no-op while the schema is at 56, and the
3041
+ thing that absorbs every future schema change automatically.
3042
+
3043
+ Callers that pre-create the DB (the GUI) get step 1 skipped; standalone
3044
+ callers (the CLI) get a fresh DB initialised here.
3045
+
3046
+ Set ``migrate=False`` to skip step 3 — the GUI uses this so it can run the
3047
+ migration as its own, separately-visible execution job (via
3048
+ ``cmd_migrate_database``) rather than burying it in the conversion job.
3049
+ """
3050
+ # Lazy imports: avoid an import-time cycle with ``update_flextool`` (which
3051
+ # imports back into ``process_inputs`` during migration) and keep the
3052
+ # heavyweight Excel reader off this module's import path.
3053
+ from flextool._resources import package_data_path
3054
+ from flextool.process_inputs.read_old_flextool import read_old_flextool
3055
+ from flextool.update_flextool.db_migration import migrate_database
3056
+ from flextool.update_flextool.initialize_database import initialize_database
3057
+
3058
+ if not _db_is_initialized(target_db_url):
3059
+ sqlite_path = _sqlite_path_from_url(target_db_url)
3060
+ if sqlite_path is None:
3061
+ raise ValueError(
3062
+ "Cannot auto-initialise a non-sqlite target; create the "
3063
+ f"database first: {target_db_url}"
3064
+ )
3065
+ template = package_data_path(OLD_FLEXTOOL_IMPORT_SCHEMA)
3066
+ logger.info("Initialising %s from frozen import template (v%d)",
3067
+ target_db_url, OLD_FLEXTOOL_IMPORT_SCHEMA_VERSION)
3068
+ initialize_database(str(template), sqlite_path)
3069
+
3070
+ data = read_old_flextool(xlsm_path)
3071
+ write_old_flextool_to_db(
3072
+ data, target_db_url, alternative_name=alternative_name, purge=purge,
3073
+ )
3074
+
3075
+ if migrate:
3076
+ logger.info("Migrating %s to the current schema version", target_db_url)
3077
+ migrate_database(target_db_url)