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,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)
|