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,589 @@
1
+ """Mock-up model builder for FlexTool.
2
+
3
+ Reads a YAML specification and generates a FlexTool-compatible JSON file
4
+ that can be imported into an initialized Spine database.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import yaml
14
+
15
+ from flextool.model_builder.encoding import (
16
+ make_array,
17
+ make_map,
18
+ make_time_series,
19
+ model_data_to_json_serializable,
20
+ param_value,
21
+ )
22
+ from flextool.model_builder.names import get_location_names
23
+ from flextool.model_builder.profiles import (
24
+ generate_autocorrelated_series,
25
+ generate_constant_series,
26
+ pert_integer_sample,
27
+ pert_sample,
28
+ )
29
+
30
+
31
+ class ModelBuilder:
32
+ """Builds a FlexTool model JSON from a YAML specification."""
33
+
34
+ def __init__(self, spec: dict, seed: int | None = None):
35
+ self.spec = spec
36
+ self.rng = np.random.default_rng(seed)
37
+ self.profile_length: int = spec.get("profile_lengths", 8760)
38
+ self.alternative = "generated"
39
+ self.scenario = "generated_scenario"
40
+
41
+ # Accumulators for JSON output
42
+ self.entities: list[list] = []
43
+ self.parameter_values: list[list] = []
44
+ self.entity_alternatives: list[list] = []
45
+
46
+ # Internal tracking
47
+ self.location_names: dict[str, list[str]] = {} # group -> [names]
48
+ self.location_distances: dict[str, list[float]] = {} # group -> [distances]
49
+ self.grid_nodes: dict[str, list[str]] = {} # grid_name -> [node_names]
50
+ self.fuel_nodes: dict[str, str] = {} # fuel_name -> node_name
51
+ self.profile_entities: set[str] = set() # track created profile entities
52
+ self.timestep_keys: list[str] = [f"t{i+1:04d}" for i in range(self.profile_length)]
53
+
54
+ def build(self) -> dict:
55
+ """Execute the full build pipeline and return the JSON structure."""
56
+ self._build_node_locations()
57
+ self._build_grids()
58
+ self._build_fuels()
59
+ self._build_profiles_and_demand()
60
+ self._build_storages()
61
+ self._build_technologies()
62
+ self._build_model_infrastructure()
63
+
64
+ return {
65
+ "entities": self.entities,
66
+ "parameter_values": self.parameter_values,
67
+ "alternatives": [[self.alternative, "Generated by model_builder"]],
68
+ "scenarios": [[self.scenario, True, "Generated scenario"]],
69
+ "scenario_alternatives": [
70
+ [self.scenario, self.alternative, None]
71
+ ],
72
+ "entity_alternatives": self.entity_alternatives,
73
+ }
74
+
75
+ # -- Helpers --
76
+
77
+ def _add_entity(self, entity_class: str, name: str | list[str], description: str | None = None) -> None:
78
+ self.entities.append([entity_class, name, description])
79
+ self.entity_alternatives.append([entity_class, [name] if isinstance(name, str) else name, self.alternative, True])
80
+
81
+ def _add_param(self, entity_class: str, entity_name: str | list[str], parameter: str, value: Any) -> None:
82
+ self.parameter_values.append(
83
+ param_value(entity_class, entity_name, parameter, value, self.alternative)
84
+ )
85
+
86
+ # -- Node Locations --
87
+
88
+ def _build_node_locations(self) -> None:
89
+ node_locations = self.spec.get("node_locations", {})
90
+ for group_name, group_spec in node_locations.items():
91
+ count = group_spec["number"]
92
+ names = get_location_names(count)
93
+ self.location_names[group_name] = names
94
+
95
+ # Sample distances between consecutive nodes
96
+ dist = group_spec["distribution"]
97
+ distances = pert_sample(
98
+ self.rng, dist["min"], dist["mode"], dist["max"], size=count
99
+ ).tolist()
100
+ self.location_distances[group_name] = distances
101
+
102
+ # -- Grids --
103
+
104
+ def _build_grids(self) -> None:
105
+ grids = self.spec.get("grids", {})
106
+ for grid_name, grid_spec in grids.items():
107
+ node_group = grid_spec["node_group"]
108
+ locations = self.location_names.get(node_group, [])
109
+ if not locations:
110
+ continue
111
+
112
+ # Create grid nodes
113
+ node_names = [f"{loc}_{grid_name}" for loc in locations]
114
+ self.grid_nodes[grid_name] = node_names
115
+
116
+ # Create a group for this grid
117
+ group_name = f"grid_{grid_name}"
118
+ self._add_entity("group", group_name)
119
+
120
+ for node_name in node_names:
121
+ self._add_entity("node", node_name)
122
+ self._add_param("node", node_name, "node_type", "balance")
123
+ self._add_entity("group__node", [group_name, node_name])
124
+
125
+ # Connections
126
+ if "connections_per_node" in grid_spec:
127
+ self._build_connections(grid_name, grid_spec, node_group)
128
+
129
+ def _build_connections(self, grid_name: str, grid_spec: dict, node_group: str) -> None:
130
+ node_names = self.grid_nodes[grid_name]
131
+ distances = self.location_distances.get(node_group, [])
132
+ n_nodes = len(node_names)
133
+
134
+ if n_nodes < 2:
135
+ return
136
+
137
+ # Compute cumulative distance from first node (1D layout)
138
+ cum_dist = [0.0]
139
+ for i in range(1, n_nodes):
140
+ cum_dist.append(cum_dist[-1] + distances[i])
141
+
142
+ # Sample target connections per node
143
+ conn_spec = grid_spec["connections_per_node"]["distribution"]
144
+ target_conns = pert_integer_sample(
145
+ self.rng, conn_spec["min"], conn_spec["mode"], conn_spec["max"], size=n_nodes
146
+ )
147
+
148
+ # Track actual connection count per node
149
+ actual_conns = [0] * n_nodes
150
+ created_connections: set[tuple[int, int]] = set()
151
+
152
+ cost_per_kw_per_km = grid_spec.get("cost-per-kw-per-km", 0)
153
+ cost_per_kw = grid_spec.get("cost-per-kw", 0)
154
+ loss_per_100km = grid_spec.get("loss-per-100km", 0)
155
+
156
+ # Pass 1: chain consecutive nodes to guarantee a connected graph.
157
+ # On the 1D layout this is the minimum spanning tree.
158
+ for i in range(n_nodes - 1):
159
+ pair = (i, i + 1)
160
+ created_connections.add(pair)
161
+ actual_conns[i] += 1
162
+ actual_conns[i + 1] += 1
163
+ self._create_connection_entity(
164
+ node_names, cum_dist, pair, cost_per_kw, cost_per_kw_per_km, loss_per_100km
165
+ )
166
+
167
+ # Pass 2: add extra connections using nearest-neighbor logic,
168
+ # respecting the remaining target count per node.
169
+ for i in range(n_nodes):
170
+ if actual_conns[i] >= target_conns[i]:
171
+ continue
172
+
173
+ # Sort other nodes by distance
174
+ others = sorted(
175
+ range(n_nodes),
176
+ key=lambda j: abs(cum_dist[j] - cum_dist[i])
177
+ )
178
+
179
+ for j in others:
180
+ if j == i:
181
+ continue
182
+ if actual_conns[i] >= target_conns[i]:
183
+ break
184
+ if actual_conns[j] >= target_conns[j]:
185
+ continue
186
+ pair = (min(i, j), max(i, j))
187
+ if pair in created_connections:
188
+ continue
189
+
190
+ created_connections.add(pair)
191
+ actual_conns[i] += 1
192
+ actual_conns[j] += 1
193
+ self._create_connection_entity(
194
+ node_names, cum_dist, pair, cost_per_kw, cost_per_kw_per_km, loss_per_100km
195
+ )
196
+
197
+ def _create_connection_entity(
198
+ self,
199
+ node_names: list[str],
200
+ cum_dist: list[float],
201
+ pair: tuple[int, int],
202
+ cost_per_kw: float,
203
+ cost_per_kw_per_km: float,
204
+ loss_per_100km: float,
205
+ ) -> None:
206
+ """Create a single connection entity with its parameters."""
207
+ node_a = node_names[pair[0]]
208
+ node_b = node_names[pair[1]]
209
+ conn_name = f"conn_{node_a}__{node_b}"
210
+ dist_km = abs(cum_dist[pair[1]] - cum_dist[pair[0]])
211
+
212
+ self._add_entity("connection", conn_name)
213
+ self._add_entity("connection__node__node", [conn_name, node_a, node_b])
214
+
215
+ invest_cost = cost_per_kw + cost_per_kw_per_km * dist_km
216
+ self._add_param("connection", conn_name, "invest_cost", invest_cost)
217
+ self._add_param("connection", conn_name, "invest_method", "invest_no_limit")
218
+ self._add_param("connection", conn_name, "transfer_method", "regular")
219
+ self._add_param("connection", conn_name, "lifetime", 40)
220
+ self._add_param("connection", conn_name, "discount_rate", 0.05)
221
+
222
+ if loss_per_100km > 0 and dist_km > 0:
223
+ efficiency = max(0.01, 1.0 - (loss_per_100km * dist_km / 100.0))
224
+ self._add_param("connection", conn_name, "efficiency", efficiency)
225
+
226
+ # -- Fuels --
227
+
228
+ def _build_fuels(self) -> None:
229
+ fuels = self.spec.get("fuels", {})
230
+ for fuel_name, fuel_spec in fuels.items():
231
+ # Create commodity
232
+ self._add_entity("commodity", fuel_name)
233
+ self._add_param("commodity", fuel_name, "price", fuel_spec["price"])
234
+
235
+ # Create fuel node (single node for the whole model)
236
+ node_name = f"{fuel_name}_source"
237
+ self._add_entity("node", node_name)
238
+ self._add_param("node", node_name, "node_type", "balance")
239
+ self.fuel_nodes[fuel_name] = node_name
240
+
241
+ # Link commodity to node
242
+ self._add_entity("commodity__node", [fuel_name, node_name])
243
+
244
+ # -- Profiles & Demand --
245
+
246
+ def _build_profiles_and_demand(self) -> None:
247
+ profile_specs = self.spec.get("profiles", {})
248
+ grids = self.spec.get("grids", {})
249
+
250
+ # Build demand for each grid
251
+ for grid_name, grid_spec in grids.items():
252
+ demand_spec = grid_spec.get("demand")
253
+ if not demand_spec:
254
+ continue
255
+
256
+ node_names = self.grid_nodes.get(grid_name, [])
257
+ if not node_names:
258
+ continue
259
+
260
+ source_profile_name = demand_spec["source"]
261
+ source_spec = profile_specs.get(source_profile_name, {})
262
+ dist = demand_spec.get("distribution", {})
263
+ nodes_with_demand = demand_spec.get("nodes_with_demand", len(node_names))
264
+
265
+ # Select which nodes get demand
266
+ if nodes_with_demand < len(node_names):
267
+ demand_indices = sorted(
268
+ self.rng.choice(len(node_names), size=nodes_with_demand, replace=False)
269
+ )
270
+ else:
271
+ demand_indices = list(range(len(node_names)))
272
+
273
+ for idx in demand_indices:
274
+ node_name = node_names[idx]
275
+
276
+ # Sample annual demand
277
+ annual_demand = float(pert_sample(
278
+ self.rng, dist["min"], dist["mode"], dist["max"], size=1
279
+ )[0])
280
+
281
+ # Generate demand profile
282
+ ts = self._generate_profile_series(source_spec)
283
+
284
+ # Set inflow (demand profile shape) and annual_flow on node
285
+ self._add_param("node", node_name, "inflow", make_time_series(ts.tolist()))
286
+ self._add_param("node", node_name, "annual_flow", annual_demand)
287
+ self._add_param("node", node_name, "inflow_method", "scale_to_annual_flow")
288
+
289
+ def _generate_profile_series(self, profile_spec: dict) -> np.ndarray:
290
+ """Generate a time series from a profile specification."""
291
+ if "constant" in profile_spec:
292
+ return generate_constant_series(profile_spec["constant"], self.profile_length)
293
+
294
+ ac = profile_spec.get("auto_correlate", {})
295
+ dist = ac.get("distribution", {})
296
+ return generate_autocorrelated_series(
297
+ self.rng,
298
+ self.profile_length,
299
+ ac.get("pattern_length_average", 24),
300
+ ac.get("pattern_length_st_dev", 0),
301
+ dist.get("min", 0),
302
+ dist.get("mode", 0.5),
303
+ dist.get("max", 1.0),
304
+ )
305
+
306
+ def _ensure_profile_entity(self, profile_name: str, profile_spec: dict, location: str) -> str:
307
+ """Create a per-node profile entity and return its name."""
308
+ entity_name = f"{profile_name}_{location}"
309
+ if entity_name not in self.profile_entities:
310
+ self.profile_entities.add(entity_name)
311
+ self._add_entity("profile", entity_name)
312
+ ts = self._generate_profile_series(profile_spec)
313
+ self._add_param("profile", entity_name, "profile", make_time_series(ts.tolist()))
314
+ return entity_name
315
+
316
+ # -- Storages --
317
+
318
+ def _build_storages(self) -> None:
319
+ storages = self.spec.get("storages", {})
320
+ for grid_name, storage_types in storages.items():
321
+ node_names = self.grid_nodes.get(grid_name, [])
322
+ if not node_names:
323
+ continue
324
+
325
+ for storage_type_name, storage_spec in storage_types.items():
326
+ storage_params = storage_spec.get("storage", {})
327
+ conn_params = storage_spec.get("connection", {})
328
+ ratio = storage_spec.get("fix_storage_to_connection_ratio")
329
+
330
+ # Determine the node_group to get location names
331
+ grid_spec = self.spec.get("grids", {}).get(grid_name, {})
332
+ node_group = grid_spec.get("node_group", "")
333
+ locations = self.location_names.get(node_group, [])
334
+
335
+ for i, grid_node in enumerate(node_names):
336
+ location = locations[i] if i < len(locations) else f"loc{i}"
337
+ storage_node_name = f"{storage_type_name}_{location}_{grid_name}"
338
+ conn_name = f"{storage_type_name}_conn_{location}_{grid_name}"
339
+
340
+ # Storage node
341
+ self._add_entity("node", storage_node_name)
342
+ self._add_param("node", storage_node_name, "node_type", "storage")
343
+ self._add_param("node", storage_node_name, "invest_method", "invest_no_limit")
344
+ self._add_param("node", storage_node_name, "storage_binding_method", "bind_within_solve")
345
+ self._add_param("node", storage_node_name, "lifetime", storage_params.get("lifetime", 20))
346
+ self._add_param("node", storage_node_name, "discount_rate", storage_params.get("discount_rate", 0.05))
347
+ if "invest_cost" in storage_params:
348
+ self._add_param("node", storage_node_name, "invest_cost", storage_params["invest_cost"])
349
+
350
+ # Storage connection (grid_node <-> storage_node)
351
+ self._add_entity("connection", conn_name)
352
+ self._add_entity("connection__node__node", [conn_name, grid_node, storage_node_name])
353
+ self._add_param("connection", conn_name, "invest_method", "invest_no_limit")
354
+ self._add_param("connection", conn_name, "transfer_method", "regular")
355
+ self._add_param("connection", conn_name, "lifetime", conn_params.get("lifetime", 20))
356
+ self._add_param("connection", conn_name, "discount_rate", conn_params.get("discount_rate", 0.05))
357
+ if "invest_cost" in conn_params:
358
+ self._add_param("connection", conn_name, "invest_cost", conn_params["invest_cost"])
359
+ if "efficiency" in conn_params:
360
+ self._add_param("connection", conn_name, "efficiency", conn_params["efficiency"])
361
+
362
+ # Constraint to tie storage capacity to connection capacity
363
+ if ratio is not None:
364
+ constraint_name = f"tie_{storage_type_name}_{location}_{grid_name}"
365
+ self._add_entity("constraint", constraint_name)
366
+ self._add_param("constraint", constraint_name, "sense", "equal")
367
+ self._add_param("constraint", constraint_name, "constant", 0.0)
368
+
369
+ # Node coefficient: +1 per MWh of storage
370
+ node_coeff = make_map([constraint_name], [1.0], index_name="constraint")
371
+ self._add_param("node", storage_node_name, "constraint_invested_capacity_coeff", node_coeff)
372
+
373
+ # Connection coefficient: -ratio per MW of connection
374
+ conn_coeff = make_map([constraint_name], [-ratio], index_name="constraint")
375
+ self._add_param("connection", conn_name, "constraint_invested_capacity_coeff", conn_coeff)
376
+
377
+ # -- Technologies --
378
+
379
+ def _build_technologies(self) -> None:
380
+ technologies = self.spec.get("technologies", {})
381
+ profile_specs = self.spec.get("profiles", {})
382
+
383
+ for source_name, sinks in technologies.items():
384
+ for sink_name, techs in sinks.items():
385
+ for tech_name, tech_spec in techs.items():
386
+ self._build_tech_units(source_name, sink_name, tech_name, tech_spec, profile_specs)
387
+
388
+ def _build_tech_units(
389
+ self,
390
+ source_name: str,
391
+ sink_name: str,
392
+ tech_name: str,
393
+ tech_spec: dict,
394
+ profile_specs: dict,
395
+ ) -> None:
396
+ """Create units for a technology across all nodes in the sink grid."""
397
+ # Determine source node(s) and sink nodes
398
+ is_fuel_source = source_name in self.fuel_nodes
399
+ is_profile_source = source_name in self.spec.get("profiles", {})
400
+
401
+ sink_nodes = self.grid_nodes.get(sink_name, [])
402
+ if not sink_nodes:
403
+ return
404
+
405
+ # For profile source, get the profile spec
406
+ profile_spec = profile_specs.get(source_name) if is_profile_source else None
407
+
408
+ # Determine the node_group for location names
409
+ sink_grid_spec = self.spec.get("grids", {}).get(sink_name, {})
410
+ sink_node_group = sink_grid_spec.get("node_group", "")
411
+ sink_locations = self.location_names.get(sink_node_group, [])
412
+
413
+ # Source grid nodes (for grid-to-grid technologies)
414
+ source_nodes = self.grid_nodes.get(source_name, [])
415
+ source_grid_spec = self.spec.get("grids", {}).get(source_name, {})
416
+ source_grid_spec.get("node_group", "")
417
+
418
+ for i, sink_node in enumerate(sink_nodes):
419
+ location = sink_locations[i] if i < len(sink_locations) else f"loc{i}"
420
+ unit_name = f"{tech_name}_{location}_{sink_name}"
421
+
422
+ self._add_entity("unit", unit_name)
423
+
424
+ # Unit parameters
425
+ self._add_param("unit", unit_name, "invest_method", "invest_no_limit")
426
+ self._add_param("unit", unit_name, "lifetime", tech_spec.get("lifetime", 20))
427
+ self._add_param("unit", unit_name, "discount_rate", tech_spec.get("discount_rate", 0.05))
428
+ if "conversion_method" in tech_spec:
429
+ self._add_param("unit", unit_name, "conversion_method", tech_spec["conversion_method"])
430
+ if "efficiency" in tech_spec:
431
+ self._add_param("unit", unit_name, "efficiency", tech_spec["efficiency"])
432
+ if "efficiency_at_min_load" in tech_spec:
433
+ self._add_param("unit", unit_name, "efficiency_at_min_load", tech_spec["efficiency_at_min_load"])
434
+ if "min_load" in tech_spec:
435
+ self._add_param("unit", unit_name, "min_load", tech_spec["min_load"])
436
+ if "invest_cost" in tech_spec:
437
+ self._add_param("unit", unit_name, "invest_cost", tech_spec["invest_cost"])
438
+ if "startup_method" in tech_spec:
439
+ self._add_param("unit", unit_name, "startup_method", tech_spec["startup_method"])
440
+ if "startup_cost" in tech_spec:
441
+ self._add_param("unit", unit_name, "startup_cost", tech_spec["startup_cost"])
442
+
443
+ # Output node relationship
444
+ self._add_entity("unit__outputNode", [unit_name, sink_node])
445
+
446
+ # Input node relationship
447
+ if is_fuel_source:
448
+ fuel_node = self.fuel_nodes[source_name]
449
+ self._add_entity("unit__inputNode", [unit_name, fuel_node])
450
+ elif is_profile_source:
451
+ # Profile-based source: unit takes from the same node (like VRE)
452
+ # Input is the output node itself (single-node conversion)
453
+ # Actually for VRE, we just need outputNode + profile, no inputNode
454
+ # Link profile
455
+ profile_entity = self._ensure_profile_entity(source_name, profile_spec, location)
456
+ self._add_entity("unit__node__profile", [unit_name, sink_node, profile_entity])
457
+ self._add_param(
458
+ "unit__node__profile",
459
+ [unit_name, sink_node, profile_entity],
460
+ "profile_method",
461
+ "upper_limit",
462
+ )
463
+ else:
464
+ # Grid-to-grid: find the matching source node at same location
465
+ if source_nodes and i < len(source_nodes):
466
+ source_node = source_nodes[i]
467
+ self._add_entity("unit__inputNode", [unit_name, source_node])
468
+
469
+ # -- Model Infrastructure --
470
+
471
+ def _build_model_infrastructure(self) -> None:
472
+ """Create model, solve, timeline, timeset, and period entities."""
473
+ # Timeline
474
+ timeline_name = "generated_timeline"
475
+ self._add_entity("timeline", timeline_name)
476
+ # All hourly timesteps
477
+ durations = make_map(
478
+ self.timestep_keys,
479
+ [1.0] * self.profile_length,
480
+ )
481
+ self._add_param("timeline", timeline_name, "timestep_duration", durations)
482
+
483
+ # Timeset
484
+ timeset_name = "generated_timeset"
485
+ self._add_entity("timeset", timeset_name)
486
+ self._add_param("timeset", timeset_name, "timeline", timeline_name)
487
+
488
+ # Solve
489
+ solve_name = "generated_solve"
490
+ self._add_entity("solve", solve_name)
491
+ self._add_param("solve", solve_name, "solve_mode", "single_solve")
492
+ self._add_param("solve", solve_name, "solver", "highs")
493
+ self._add_param(
494
+ "solve", solve_name, "period_timeset",
495
+ make_map(["p2025"], [timeset_name]),
496
+ )
497
+ self._add_param(
498
+ "solve", solve_name, "invest_periods",
499
+ make_array(["p2025"]),
500
+ )
501
+ self._add_param(
502
+ "solve", solve_name, "realized_periods",
503
+ make_array(["p2025"]),
504
+ )
505
+ self._add_param(
506
+ "solve", solve_name, "years_represented",
507
+ make_map(["p2025"], [1.0], index_name="period"),
508
+ )
509
+
510
+ # Model
511
+ model_name = "generated_model"
512
+ self._add_entity("model", model_name)
513
+ self._add_param(
514
+ "model", model_name, "solves",
515
+ make_array([solve_name]),
516
+ )
517
+
518
+
519
+ def build_model(
520
+ yaml_path: str | Path,
521
+ output_path: str | Path | None = None,
522
+ seed: int | None = None,
523
+ db_url: str | None = None,
524
+ ) -> dict:
525
+ """Build a FlexTool model from a YAML specification file.
526
+
527
+ Can output to JSON file and/or import directly into a Spine database.
528
+
529
+ Args:
530
+ yaml_path: Path to the YAML specification file.
531
+ output_path: Optional path for the output JSON file.
532
+ If None and no db_url, uses the YAML filename with .json extension.
533
+ seed: Optional random seed for reproducibility.
534
+ db_url: Optional database URL for direct import (e.g. 'sqlite:///path.sqlite').
535
+ If provided, imports data directly using spinedb_api.
536
+
537
+ Returns:
538
+ The generated model data dictionary (with raw Python values).
539
+ """
540
+ yaml_path = Path(yaml_path)
541
+ with open(yaml_path) as f:
542
+ spec = yaml.safe_load(f)
543
+
544
+ builder = ModelBuilder(spec, seed=seed)
545
+ result = builder.build()
546
+
547
+ print(f"Generated model from: {yaml_path}")
548
+ print(f" Entities: {len(result['entities'])}")
549
+ print(f" Parameter values: {len(result['parameter_values'])}")
550
+
551
+ # Write JSON file
552
+ if output_path is not None or db_url is None:
553
+ if output_path is None:
554
+ output_path = yaml_path.with_suffix(".json")
555
+ output_path = Path(output_path)
556
+ json_data = model_data_to_json_serializable(result)
557
+ with open(output_path, "w") as f:
558
+ json.dump(json_data, f, indent=2)
559
+ print(f" JSON written to: {output_path}")
560
+
561
+ # Direct database import
562
+ if db_url is not None:
563
+ from spinedb_api import DatabaseMapping, import_data
564
+
565
+ with DatabaseMapping(db_url) as db:
566
+ num, log = import_data(db, **result)
567
+ print(f" Database import: {num} items")
568
+ if log:
569
+ for entry in log[:10]:
570
+ print(f" WARNING: {entry}")
571
+ db.commit_session("Imported generated model")
572
+ print(f" Imported to: {db_url}")
573
+
574
+ return result
575
+
576
+
577
+ def main() -> None:
578
+ parser = argparse.ArgumentParser(description="Generate a FlexTool mock-up model from YAML specification")
579
+ parser.add_argument("yaml_file", help="Path to the YAML specification file")
580
+ parser.add_argument("-o", "--output", help="Output JSON file path")
581
+ parser.add_argument("--db", help="Database URL for direct import (e.g. sqlite:///path.sqlite)")
582
+ parser.add_argument("--seed", type=int, help="Random seed for reproducibility")
583
+ args = parser.parse_args()
584
+
585
+ build_model(args.yaml_file, args.output, args.seed, args.db)
586
+
587
+
588
+ if __name__ == "__main__":
589
+ main()
@@ -0,0 +1,67 @@
1
+ """Helpers for building parameter values for FlexTool model data.
2
+
3
+ Uses spinedb_api Map/Array objects for complex values. Provides two output modes:
4
+ - Raw Python objects for direct import via spinedb_api.import_data()
5
+ - Base64-encoded JSON for writing importable JSON files
6
+ """
7
+
8
+ import base64
9
+ from typing import Any
10
+
11
+ from spinedb_api import Map, Array, to_database
12
+
13
+
14
+ def make_map(
15
+ keys: list[str], values: list[float | str], index_name: str | None = None
16
+ ) -> Map:
17
+ """Build a spinedb_api Map (rank-1)."""
18
+ return Map(keys, values, index_name=index_name)
19
+
20
+
21
+ def make_array(values: list[str], index_name: str | None = None) -> Array:
22
+ """Build a spinedb_api Array."""
23
+ return Array(values, index_name=index_name)
24
+
25
+
26
+ def make_time_series(
27
+ timestep_values: list[float], num_timesteps: int | None = None
28
+ ) -> Map:
29
+ """Build a time series Map with t0001..tNNNN keys."""
30
+ n = num_timesteps or len(timestep_values)
31
+ keys = [f"t{i+1:04d}" for i in range(n)]
32
+ return Map(keys, list(timestep_values[:n]))
33
+
34
+
35
+ def param_value(
36
+ entity_class: str,
37
+ entity_name: str | list[str],
38
+ parameter: str,
39
+ value: Any,
40
+ alternative: str,
41
+ ) -> list:
42
+ """Build a single parameter_values entry with raw Python value."""
43
+ return [entity_class, entity_name, parameter, value, alternative]
44
+
45
+
46
+ def encode_for_json(value: Any) -> list:
47
+ """Encode a value to [base64_string, type_string] for JSON file output."""
48
+ db_value, db_type = to_database(value)
49
+ b64 = base64.b64encode(db_value).decode("utf-8")
50
+ return [b64, db_type]
51
+
52
+
53
+ def model_data_to_json_serializable(data: dict) -> dict:
54
+ """Convert model data with raw Python values to JSON-serializable format.
55
+
56
+ Encodes all parameter values from raw objects (Map, Array, float, str)
57
+ to [base64, type] tuples suitable for JSON output.
58
+ """
59
+ result = dict(data)
60
+ if "parameter_values" in result:
61
+ encoded_pvs = []
62
+ for pv in result["parameter_values"]:
63
+ entity_class, entity_name, param, value, alt = pv
64
+ encoded = encode_for_json(value)
65
+ encoded_pvs.append([entity_class, entity_name, param, encoded, alt])
66
+ result["parameter_values"] = encoded_pvs
67
+ return result