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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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
|