Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ def get_line_arc(component: PLEXOSLine, context: PluginContext) -> Result[Arc, A
if not from_node or not to_node:
raise ValueError(f"Could not find both nodes for line {component.name}. Memberships: {memberships}")

arc_name = f"{from_node}-{to_node}"

# Reuse existing Arc if one with the same name already exists in the target system
existing_arcs = list(context.target_system.get_components(Arc))
existing_arc = next((a for a in existing_arcs if getattr(a, "name", None) == arc_name), None)
if existing_arc is not None:
return Ok(existing_arc)

acbuses = list(context.target_system.get_components(ACBus))
from_bus = next((bus for bus in acbuses if getattr(bus, "name", None) == from_node), None)
to_bus = next((bus for bus in acbuses if getattr(bus, "name", None) == to_node), None)
Expand All @@ -259,7 +267,7 @@ def get_line_arc(component: PLEXOSLine, context: PluginContext) -> Result[Arc, A
f"Available: {[bus.name for bus in acbuses]}"
)

arc_sense = Arc(name=f"{from_node}-{to_node}", from_to=from_bus, to_from=to_bus)
arc_sense = Arc(name=arc_name, from_to=from_bus, to_from=to_bus)
return Ok(arc_sense)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ def discharge_efficiency_percent(
gen_technology = getattr(component, "technology", "")
efficiency = getattr(component, "round_trip_efficiency", None)

if efficiency is not None:
if efficiency is not None and efficiency != 0.0:
return Ok(_float_or_zero(efficiency) * 100.0)

default_efficiency = _get_defaults(gen_technology, "discharge_efficiency")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
from r2x_core import System

if TYPE_CHECKING:
from r2x_core import System, TranslationContext
from r2x_core import PluginContext, System


def attach_region_load_time_series(context: TranslationContext) -> None:
def attach_region_load_time_series(context: PluginContext) -> None:
"""Attach demand load and time series from ReEDSDemand to the translated PLEXOSRegion."""
from r2x_plexos.models import PLEXOSRegion
from r2x_reeds.models.components import ReEDSDemand
Expand Down Expand Up @@ -56,7 +56,7 @@ def attach_region_load_time_series(context: TranslationContext) -> None:
logger.debug("Attached demand time series {} to region {}", ts.name, region_name)


def attach_reserve_time_series(context: TranslationContext) -> None:
def attach_reserve_time_series(context: PluginContext) -> None:
"""Attach only min_provision time series from ReEDSReserve to the translated PLEXOSReserve."""
from r2x_plexos.models import PLEXOSReserve
from r2x_reeds.models.components import ReEDSReserve
Expand All @@ -73,7 +73,7 @@ def attach_reserve_time_series(context: TranslationContext) -> None:
context.target_system.add_time_series(deepcopy(ts), reserve)


def attach_time_series_to_generators(context: TranslationContext) -> None:
def attach_time_series_to_generators(context: PluginContext) -> None:
"""Transfer time series from ReEDS generators to translated PLEXOS generators (with duplicate check)."""
from r2x_reeds.models.components import ReEDSGenerator, ReEDSHydroGenerator, ReEDSVariableGenerator

Expand Down Expand Up @@ -106,7 +106,7 @@ def attach_time_series_to_generators(context: TranslationContext) -> None:
continue


def ensure_region_node_memberships(context: TranslationContext) -> None:
def ensure_region_node_memberships(context: PluginContext) -> None:
"""Ensure every translated region has a node child membership with matching name."""
system = context.target_system
nodes_by_name = {node.name: node for node in system.get_components(PLEXOSNode)}
Expand All @@ -118,7 +118,7 @@ def ensure_region_node_memberships(context: TranslationContext) -> None:
_ensure_membership(system, node, region, CollectionEnum.Region)


def ensure_generator_node_memberships(context: TranslationContext) -> None:
def ensure_generator_node_memberships(context: PluginContext) -> None:
"""Ensure every translated generator has a node membership based on its source region."""
from r2x_reeds.models import ReEDSGenerator

Expand All @@ -142,7 +142,7 @@ def ensure_generator_node_memberships(context: TranslationContext) -> None:
_ensure_membership(context.target_system, target_gen, node, CollectionEnum.Nodes)


def link_line_memberships(context: TranslationContext) -> None:
def link_line_memberships(context: PluginContext) -> None:
"""Connect translated lines to their originating region nodes."""
from r2x_reeds.models.components import ReEDSTransmissionLine

Expand All @@ -163,7 +163,7 @@ def link_line_memberships(context: TranslationContext) -> None:
_ensure_membership(context.target_system, to_node, plexos_line, CollectionEnum.NodeTo)


def attach_emissions_to_generators(context: TranslationContext) -> None:
def attach_emissions_to_generators(context: PluginContext) -> None:
"""Copy ReEDS emission metadata onto translated generators."""
from r2x_reeds.models.components import ReEDSEmission, ReEDSGenerator

Expand All @@ -181,7 +181,7 @@ def attach_emissions_to_generators(context: TranslationContext) -> None:
context.target_system.add_supplemental_attribute(plexos_gen, emission.model_copy())


def convert_pumped_storage_generators(context: TranslationContext) -> None:
def convert_pumped_storage_generators(context: PluginContext) -> None:
"""Ensure pumped-storage generators also exist as storage components."""
from r2x_reeds.models.components import ReEDSGenerator

Expand Down
2 changes: 0 additions & 2 deletions packages/r2x-reeds-to-plexos/tests/test_getters.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ def test_basic_getters_return_values(tmp_path):

# Generator getters
assert getters.rating(objs["thermal"], context).unwrap() == 50.0
assert getters.fixed_load(objs["thermal"], context).unwrap() == 0.0
assert getters.load_subtracter(objs["thermal"], context).unwrap() == 0.0

# Storage getters
Expand Down Expand Up @@ -207,7 +206,6 @@ def test_basic_getters_return_values(tmp_path):

# Hydro getters
assert getters.hydro_min_flow(objs["hydro"], context).unwrap() == 0.0
assert getters.hydro_ramp_rate_mw_per_hour(objs["hydro"], context).unwrap() == 120.0
assert getters.hydro_must_run_flag(objs["hydro"], context).unwrap() == 1

# Consuming tech
Expand Down
170 changes: 148 additions & 22 deletions packages/r2x-sienna-to-plexos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,167 @@ This package provides a translation plugin to convert Sienna system models into
### Basic Example

```python
from r2x_sienna_to_plexos.translation import SiennaToPlexosTranslation
import json
from importlib.resources import files
from pathlib import Path
from typing import cast

from infrasys.time_series_manager import TimeSeriesManager
from infrasys.time_series_models import TimeSeriesStorageType
from infrasys.utils.sqlite import create_in_memory_db
from r2x_plexos import PLEXOSConfig
from r2x_plexos.exporter import PLEXOSExporter
from r2x_sienna.parser import SiennaParser
from r2x_sienna.plugin_config import SiennaConfig
from r2x_sienna_to_plexos.getters_utils import (
ensure_battery_node_memberships,
ensure_generator_node_memberships,
ensure_head_storage_generator_membership,
ensure_interface_line_memberships,
ensure_node_zone_memberships,
ensure_region_node_memberships,
ensure_reserve_battery_memberships,
ensure_reserve_generator_memberships,
ensure_tail_storage_generator_membership,
ensure_transformer_node_memberships,
)

from r2x_core import PluginContext, Rule, System, apply_rules_to_context
from r2x_core.logger import setup_logging
from r2x_core.store import DataStore

setup_logging(verbosity=2)

# =====================================
# Paths
# =====================================
sys_name = "MySystem"
sys = Path("path/to/sys" / f"{sys_name}.json")

# =====================================
# Sienna Parser
# =====================================
weather_year = 2012
model_year = 2029
json_path = Path(sys)
sienna_config = SiennaConfig(
json_path=str(json_path),
model_year=model_year,
system_name=sys_name,
skip_validation=True,
models=("r2x_sienna.models", "r2x_plexos.models"),
)
store = DataStore.from_data_files([], path=json_path.parent)
context = PluginContext(
config=sienna_config,
store=store,
skip_validation=True,
)
parser = cast(SiennaParser, SiennaParser.from_context(context))

sienna_sys = parser.run()
sienna_sys = sienna_sys.system
context.source_system = sienna_sys

tmp_dir = sienna_sys.get_time_series_directory()
sienna_sys.convert_storage(
time_series_directory=tmp_dir,
time_series_storage_type=TimeSeriesStorageType.ARROW,
permanent=True,
)

# =====================================
# Rules Definition
# =====================================
rules_path = files("r2x_sienna_to_plexos.config") / "rules.json"
rules = Rule.from_records(json.loads(rules_path.read_text()))
context.rules = rules

# Define level of printing to show progress
setup_logging(level="DEBUG")
# =====================================
# Sienna to PLEXOS (Translation)
# =====================================
connection = create_in_memory_db()
ts_manager = TimeSeriesManager(
connection,
time_series_directory=tmp_dir,
time_series_storage_type=TimeSeriesStorageType.ARROW,
permanent=True,
)

# Initialize the translation
translation = SiennaToPlexosTranslation(
sienna_file="/path/to/sienna/system.json",
output_folder="/path/to/output",
case_name="my_case",
model_year=2029,
scenario="Base",
system_base_power=100.0,
plexos_sys = System(
name="PLEXOS",
auto_add_composed_components=True,
time_series_manager=ts_manager,
)
context.target_system = plexos_sys

apply_rules_to_context(context)
ensure_region_node_memberships(context)
ensure_generator_node_memberships(context)
ensure_battery_node_memberships(context)
ensure_node_zone_memberships(context)
ensure_reserve_battery_memberships(context)
ensure_reserve_generator_memberships(context)
ensure_transformer_node_memberships(context)
ensure_interface_line_memberships(context)
ensure_head_storage_generator_membership(context)
ensure_tail_storage_generator_membership(context)

# =====================================
# PLEXOS Exporter
# =====================================
results_dir = "path/to/output_dir"
results_dir.mkdir(exist_ok=True)

# Run the translation
translation.run()
plexos_config = PLEXOSConfig(
model_name=case_name,
timeseries_dir=output_path,
horizon_year=weather_year,
)
exporter_context = PluginContext(
config=plexos_config,
system=plexos_sys,
)
exporter = PLEXOSExporter.from_context(exporter_context)
exporter.output_path = results_dir
exporter.solve_year = model_year
exporter.weather_year = weather_year

# Run with upgrader (if needed to upgrade Sienna data from psy4 to psy5 format)
translation.run(run_upgrader=True)
exporter.on_export()
```

### Parameters

- **`sienna_file`** (str): Path to the Sienna system JSON file containing the input data.
- **`output_folder`** (str): Path to the directory where output files will be saved.
- **`case_name`** (str): Name of the case (used for output file naming).
- **`model_year`** (int, optional): Model year for the Sienna system. Default is `2029`.
- **`scenario`** (str, optional): Scenario name. Default is `"Base"`.
- **`system_base_power`** (float, optional): System base power in MVA. Default is `100.0`.
#### `SiennaConfig`

- **`json_path`** (str): Path to the Sienna system JSON file (e.g., `"/path/to/MySystem/MySystem.json"`).
- **`model_year`** (int, optional): Model year for the simulation. Default is `2029`.
- **`system_name`** (str): Name of the system (used for file naming and identification).
- **`skip_validation`** (bool, optional): Skip validation during parsing. Default is `False`.
- **`exclude_defaults`** (bool, optional): Exclude default values in PLEXOS export. Default is `True`.
- **`models`** (tuple, optional): Model namespaces to load. Default includes `r2x_sienna.models` and `r2x_plexos.models`.

#### `PLEXOSConfig`

- **`model_name`** (str): Name of the PLEXOS model (used for output file naming).
- **`timeseries_dir`** (str): Path to the directory where time series and output files will be saved.

## Translation Rules

Translation rules are defined in `config/rules.json`. These rules specify how Sienna components are mapped to PLEXOS components, including field mappings, getters, and filters.

## Membership Utilities

After applying the main translation rules, the following membership helpers must be called to wire up component relationships in the PLEXOS system:

| Function | Description |
|---|---|
| `ensure_region_node_memberships` | Links regions to their child nodes |
| `ensure_generator_node_memberships` | Links generators to their buses |
| `ensure_battery_node_memberships` | Links batteries to their buses |
| `ensure_node_zone_memberships` | Links nodes to their load zones |
| `ensure_reserve_generator_memberships` | Links reserves to contributing generators |
| `ensure_reserve_battery_memberships` | Links reserves to contributing batteries |
| `ensure_transformer_node_memberships` | Links transformers to their from/to nodes |
| `ensure_interface_line_memberships` | Links interfaces to their member lines |
| `ensure_head_storage_generator_membership` | Links head storage to its generator |
| `ensure_tail_storage_generator_membership` | Links tail storage to its generator |
Loading