diff --git a/src/r2x/exporter/plexos.py b/src/r2x/exporter/plexos.py index ae2c09a0..fdfd2a62 100644 --- a/src/r2x/exporter/plexos.py +++ b/src/r2x/exporter/plexos.py @@ -41,6 +41,7 @@ Transformer2W, TransmissionInterface, ) +from r2x.models.branch import Line from r2x.units import get_magnitude from r2x.utils import custom_attrgetter, get_enum_from_string, read_json @@ -211,6 +212,8 @@ def insert_component_properties( match component_type.__name__: case "GenericBattery": custom_map = {"active_power": "Max Power", "storage_capacity": "Capacity"} + case "Line": + custom_map = {"rating": "Max Flow"} case _: custom_map = {} property_map = self.property_map | custom_map @@ -247,7 +250,9 @@ def add_component_category( class_id = self._db_mgr.get_class_id(class_enum) categories = [ (class_id, rank, category or "") - for rank, category in enumerate(sorted(component_categories), start=existing_rank + 1) + for rank, category in enumerate( + sorted(component_categories, key=lambda x: (x is None, x)), start=existing_rank + 1 + ) ] # Maybe replace `t_category` with right schema. @@ -415,6 +420,8 @@ def add_lines(self) -> None: # NOTE: The default line on Plexos is a `MonitoredLine` without category. If we need to add a category # in the future, we will uncomment the line below with the pertinent category name. # self.add_component_category(MonitoredLine, class_enum=ClassEnum.Line) + self.bulk_insert_objects(Line, class_enum=ClassEnum.Line, collection_enum=CollectionEnum.Lines) + self.insert_component_properties(Line, parent_class=ClassEnum.System, collection=CollectionEnum.Lines) self.bulk_insert_objects( MonitoredLine, class_enum=ClassEnum.Line, collection_enum=CollectionEnum.Lines ) @@ -426,7 +433,7 @@ def add_lines(self) -> None: collection_properties = self._db_mgr.get_valid_properties( collection=CollectionEnum.Lines, parent_class=ClassEnum.System, child_class=ClassEnum.Line ) - for line in self.system.get_components(MonitoredLine, filter_func=lambda x: getattr(x, "ext", False)): + for line in self.system.get_components(MonitoredLine, Line): properties = get_export_properties( line.ext, partial(apply_property_map, property_map=self.property_map), diff --git a/src/r2x/models/costs.py b/src/r2x/models/costs.py index 40e4f5e2..83b13e2a 100644 --- a/src/r2x/models/costs.py +++ b/src/r2x/models/costs.py @@ -2,8 +2,9 @@ from typing import Annotated from infrasys.models import InfraSysBaseModelWithIdentifers +from infrasys.value_curves import LinearCurve from pydantic import Field, computed_field -from infrasys.cost_curves import ProductionVariableCostCurve +from infrasys.cost_curves import FuelCurve, ProductionVariableCostCurve from r2x.units import Currency, FuelPrice from operator import attrgetter @@ -59,6 +60,15 @@ class ThermalGenerationCost(OperationalCost): start_up: Annotated[Currency | None, Field(description="Cost to start the unit.")] = Currency(0, "usd") variable: ProductionVariableCostCurve | None = None + @classmethod + def example(cls) -> "ThermalGenerationCost": + return ThermalGenerationCost( + fixed=FuelPrice(0, "usd/MWh"), + shut_down=Currency(100, "usd"), + start_up=Currency(100, "usd"), + variable=FuelCurve(value_curve=LinearCurve(10)), # type: ignore + ) + class StorageCost(OperationalCost): charge_variable_cost: ProductionVariableCostCurve | None = None diff --git a/src/r2x/parser/parser_helpers.py b/src/r2x/parser/parser_helpers.py index b7d4bc59..4fbb9e4a 100644 --- a/src/r2x/parser/parser_helpers.py +++ b/src/r2x/parser/parser_helpers.py @@ -144,10 +144,10 @@ def fill_missing_timestamps(data_file: pl.DataFrame, hourly_time_index: pl.DataF ... ).to_frame("datetime") >>> fill_missing_timestamps(df, hourly_time_index) """ - # Match case based on available columns - match data_file.columns: + column_set = set(data_file.columns) + match column_set: # Case when "year", "month", "day", and "hour" are all present - case ["year", "month", "day", "hour", *_]: + case s if s.issuperset({"year", "month", "day", "hour"}): data_file = data_file.with_columns( pl.datetime(pl.col("year"), pl.col("month"), pl.col("day"), pl.col("hour")) ) @@ -155,13 +155,13 @@ def fill_missing_timestamps(data_file: pl.DataFrame, hourly_time_index: pl.DataF return upsample_data.fill_null(strategy="forward") # Case when "year", "month", and "day" are present but "hour" is missing - case ["year", "month", "day", *_]: + case s if s.issuperset({"year", "month", "day"}): data_file = data_file.with_columns(pl.datetime(pl.col("year"), pl.col("month"), pl.col("day"))) upsample_data = hourly_time_index.join(data_file, on="datetime", how="left") return upsample_data.fill_null(strategy="forward") # Case when "day" is missing, but "year" and "month" are present - case ["year", "month", *_] if "day" not in data_file.columns: + case s if s.issuperset({"year", "month"}): data_file = data_file.with_columns(pl.datetime(pl.col("year"), pl.col("month"), 1)) # First day upsample_data = hourly_time_index.join(data_file, on="datetime", how="left") return upsample_data.fill_null(strategy="forward") @@ -200,16 +200,20 @@ def resample_data_to_hourly(data_file: pl.DataFrame) -> pl.DataFrame: # Expecting two rows: one for hour 0 and one for hour 1 """ # Create a timestamp from year, month, day, hour, and minute + if max(data_file["period"]) == 48.0: # sub-hourly data + data_file = data_file.with_columns(((pl.col("period") - 1) / 2).cast(pl.Int32).alias("hour")) data_file = data_file.with_columns( pl.datetime( data_file["year"], data_file["month"], data_file["day"], hour=data_file["hour"], - minute=data_file["minute"], + # minute=data_file["minute"], ).alias("timestamp") ) + data_file = data_file.drop_nulls().sort("timestamp") + # Group by the hour and aggregate the values return ( data_file.group_by_dynamic("timestamp", every="1h") diff --git a/src/r2x/parser/plexos.py b/src/r2x/parser/plexos.py index f1a51b6a..c328d643 100644 --- a/src/r2x/parser/plexos.py +++ b/src/r2x/parser/plexos.py @@ -167,7 +167,12 @@ def __init__(self, *args, xml_file: str | None = None, **kwargs) -> None: # TODO(pesap): Rename exceptions to include R2X # https://github.com/NREL/R2X/issues/5 # R2X needs at least one of this maps defined to correctly work. - if not self.fuel_map and not self.device_map and not self.device_match_string: + if ( + not self.fuel_map + and not self.device_map + and not self.device_match_string + and not self.category_map + ): msg = ( "Neither `plexos_fuel_map` or `plexos_device_map` or `device_match_string` was provided. " "To fix, provide any of the mappings." @@ -858,7 +863,7 @@ def _construct_value_curves(self, record, generator_name): # noqa: C901 heat_rate_avg = ( Quantity( np.median(heat_rate_avg.data), - units=heat_rate_avg.units, + units=heat_rate_avg.data.units, ) if isinstance(heat_rate_avg, SingleTimeSeries) else heat_rate_avg @@ -866,7 +871,7 @@ def _construct_value_curves(self, record, generator_name): # noqa: C901 heat_rate_base = ( Quantity( np.median(heat_rate_base.data), - units=heat_rate_base.units, + units=heat_rate_base.data.units, ) if isinstance(heat_rate_base, SingleTimeSeries) else heat_rate_base @@ -874,7 +879,7 @@ def _construct_value_curves(self, record, generator_name): # noqa: C901 heat_rate_incr = ( Quantity( np.median(heat_rate_incr.data), - units=heat_rate_incr.units, + units=heat_rate_incr.data.units, ) if isinstance(heat_rate_incr, SingleTimeSeries) else heat_rate_incr @@ -1091,7 +1096,7 @@ def _set_unit_capacity(self, record): if rating_factor := record.get("Rating Factor"): if isinstance(rating_factor, SingleTimeSeries): rating_factor_data = rating_factor.data - if rating_factor.units == "percent": + if rating_factor_data.units == "percent": rating_factor_data = rating_factor_data.to("") if not (max_active_power := record.get("max_active_power")): # Order of the operation matters @@ -1195,7 +1200,7 @@ def _construct_load_profiles(self): if max_active_power := mapped_records.get("max_active_power"): max_load = ( - np.max(max_active_power.data) + np.nanmax(max_active_power.data) if isinstance(max_active_power, SingleTimeSeries) else max_active_power ) diff --git a/src/r2x/parser/plexos_utils.py b/src/r2x/parser/plexos_utils.py index f90d0fdf..6306c758 100644 --- a/src/r2x/parser/plexos_utils.py +++ b/src/r2x/parser/plexos_utils.py @@ -26,7 +26,7 @@ class DATAFILE_COLUMNS(Enum): # noqa: N801 """Enum of possible Data file columns in Plexos.""" NV = ("name", "value") - Y = "year" + Y = ("year",) PV = ("pattern", "value") TS_NPV = ("name", "pattern", "value") TS_NYV = ("name", "year", "value") diff --git a/tests/models/pjm.py b/tests/models/pjm.py index ae6b4fee..ef6a2d30 100644 --- a/tests/models/pjm.py +++ b/tests/models/pjm.py @@ -30,7 +30,8 @@ def pjm_2area() -> System: # Add topology elements system.add_component(Area(name="init")) system.add_component(Area(name="Area2")) - system.add_component(LoadZone(name="LoadZone1")) + system.add_component(LoadZone(name="init")) + system.add_component(LoadZone(name="Area2")) for bus in pjm_2area_components["bus"]: system.add_component( @@ -40,6 +41,7 @@ def pjm_2area() -> System: bus_type=ACBusTypes(bus["bustype"]), base_voltage=Voltage(bus["base_voltage"], "kV"), area=system.get_component(Area, bus["area"]), + load_zone=system.get_component(LoadZone, bus["area"]), magnitude=bus["magnitude"], ) ) @@ -68,7 +70,7 @@ def pjm_2area() -> System: x=0.03, to_bus=bust, rating_up=1000 * ureg.MW, - rating=1000 * ureg.MW, + rating_down=-1000 * ureg.MW, ) system.add_component(branch_monitored) @@ -97,14 +99,15 @@ def pjm_2area() -> System: startup_cost=gen["StartupCost"] * ureg.Unit("usd"), shutdown_cost=gen["ShutDnCost"] * ureg.Unit("usd"), vom_price=gen["VOM"] * ureg.Unit("usd/MWh"), - min_down_time=gen["MinTimeDn"], - min_up_time=gen["MinTimeUp"], + min_down_time=Time(gen["MinTimeDn"], "hour"), + min_up_time=Time(gen["MinTimeUp"], "hour"), mean_time_to_repair=Time(10.0, "hour"), forced_outage_rate=Percentage(0.0), planned_outage_rate=Percentage(0.0), ramp_up=gen["RampLimitsUp"], ramp_down=gen["RampLimitsDn"], bus=system.get_component(ACBus, gen["BusName"]), + category="thermal", ) ) @@ -169,7 +172,7 @@ def pjm_2area() -> System: reserve_map = ReserveMap(name="pjm_reserve_map") reserve = Reserve( name="SpinUp-pjm", - region=system.get_component(LoadZone, "LoadZone1"), + region=system.get_component(LoadZone, "init"), reserve_type=ReserveType.SPINNING, vors=0.05, duration=3600.0, diff --git a/tests/test_parser_helper.py b/tests/test_parser_helper.py index 20d3b56a..57a59638 100644 --- a/tests/test_parser_helper.py +++ b/tests/test_parser_helper.py @@ -146,6 +146,9 @@ def test_fill_missing_timestamps(): # Assert that the result contains 24 rows (for each hour of the day) assert len(result) == 24 + data_file = pl.DataFrame({"name": ["testname"], "year": [2020], "month": [1], "value": [1]}) + result = fill_missing_timestamps(data_file, hourly_time_index) + data_file = pl.DataFrame({"year": [2020], "value": [1]}) with pytest.raises(ValueError): _ = fill_missing_timestamps(data_file, hourly_time_index) diff --git a/tests/test_plexos_utils.py b/tests/test_plexos_utils.py index 7ec5276d..b4bec79a 100644 --- a/tests/test_plexos_utils.py +++ b/tests/test_plexos_utils.py @@ -6,7 +6,9 @@ def test_get_column_enum(): """Test multiple cases for get_column_enum function.""" - # Case 1: Exact match for NV + columns = ["year", "random_column", "random_column_2"] + assert get_column_enum(columns) == DATAFILE_COLUMNS.Y + columns = ["name", "value"] assert get_column_enum(columns) == DATAFILE_COLUMNS.NV