Skip to content

Commit

Permalink
test: Adding further testing
Browse files Browse the repository at this point in the history
  • Loading branch information
pesap committed Oct 22, 2024
1 parent 0e1e98c commit fe667d7
Show file tree
Hide file tree
Showing 12 changed files with 400 additions and 55 deletions.
46 changes: 32 additions & 14 deletions src/r2x/exporter/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,30 +123,48 @@ def export_data_files(self, year: int, time_series_folder: str = "Data") -> None
continue
self.time_series_name_by_type[ts_component_name].append(component.name)

date_time_column = pd.date_range(
start=f"1/1/{year}",
end=f"1/1/{year + 1}",
freq="1h",
inclusive="left",
)
date_time_column = np.datetime_as_string(date_time_column, unit="m")
# Remove leap day to match ReEDS convention
# date_time_column = date_time_column[~((date_time_column.month == 2) & (date_time_column.day == 29))]
if self.input_model == "reeds-US":
date_time_column = date_time_column[:-24]

component_lengths = {
component_type: {ts.length}
for component_type, time_series in self.time_series_objects.items()
for ts in time_series
}

inconsistent_lengths = [
(component_type, length_set)
for component_type, length_set in component_lengths.items()
if len(length_set) != 1
]
if inconsistent_lengths:
raise ValueError(f"Multiple lengths found for components time series: {inconsistent_lengths}")

datetime_arrays = {
component_type: (
np.datetime_as_string(
pd.date_range(
start=f"1/1/{year}",
periods=ts.length,
freq=f"{int(ts.resolution.total_seconds() / 60)}min", # Convert resolution to minutes
),
unit="m",
),
time_series,
)
for component_type, time_series in self.time_series_objects.items()
for ts in time_series
}
csv_fpath = self.output_folder / time_series_folder

# Use string substitution to dynamically change the output csv fnames
csv_fname = config_dict.get("time_series_fname", "${component_type}_${name}_${weather_year}.csv")
logger.trace("Using {} as time_series name", csv_fname)
string_template = string.Template(csv_fname)

for component_type, time_series in self.time_series_objects.items():
for component_type, (datetime_array, time_series) in datetime_arrays.items():
time_series_arrays = list(map(lambda x: x.data.to_numpy(), time_series))

config_dict["component_type"] = component_type
csv_fname = string_template.safe_substitute(config_dict)
csv_table = np.column_stack([date_time_column, *time_series_arrays])
csv_table = np.column_stack([datetime_array, *time_series_arrays])
header = '"DateTime",' + ",".join(
[f'"{name}"' for name in self.time_series_name_by_type[component_type]]
)
Expand Down
14 changes: 8 additions & 6 deletions src/r2x/exporter/sienna.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
TABLE_DATA_SPEC = "src/descriptors/power_system_inputs.json"


def get_psy_fields() -> dict[str, Any]:
"""Get PSY JSON schema."""
request = urlopen(PSY_URL + TABLE_DATA_SPEC)
descriptor = json.load(request)
return descriptor


class SiennaExporter(BaseExporter):
"""Sienna exporter class.
Expand Down Expand Up @@ -73,11 +80,6 @@ def __init__(self, *args, **kwargs):
raise NotImplementedError(msg)
self.year: int = self.config.solve_year

def _get_table_data_fields(self) -> dict[str, Any]:
request = urlopen(PSY_URL + TABLE_DATA_SPEC)
descriptor = json.load(request)
return descriptor

def run(self, *args, path=None, **kwargs) -> "SiennaExporter":
"""Run sienna exporter workflow.
Expand Down Expand Up @@ -386,7 +388,7 @@ def process_storage_data(self, fname="storage.csv") -> None:
hydro_pump = list(self.system.to_records(HydroPumpedStorage))
storage_list = generic_storage + hydro_pump

if storage_list is None:
if not storage_list:
logger.warning("No storage devices found")
return

Expand Down
2 changes: 1 addition & 1 deletion src/r2x/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Models
# ruff: noqa
from .branch import Branch, ACBranch, DCBranch, MonitoredLine, Transformer2W
from .core import ReserveMap, TransmissionInterfaceMap
from .core import ReserveMap, TransmissionInterfaceMap, MinMax
from .generators import (
Generator,
ThermalGen,
Expand Down
2 changes: 1 addition & 1 deletion src/r2x/models/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class Generator(Device):
] = None

@field_serializer("active_power_limits")
def serialize_address(self, min_max: MinMax) -> dict[str, Any]:
def serialize_active_power_limits(self, min_max: MinMax) -> dict[str, Any]:
if min_max is not None:
return {"min": min_max.min, "max": min_max.max}

Expand Down
157 changes: 132 additions & 25 deletions src/r2x/parser/parser_helpers.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,76 @@
"""Set of helper functions for parsers."""
# ruff: noqa

from typing import Any
import polars as pl
import numpy as np
import cvxpy as cp

from infrasys.function_data import QuadraticFunctionData, PiecewiseLinearData, XYCoords


def field_filter(property_fields, eligible_fields):
def field_filter(
property_fields: dict[str, Any], eligible_fields: set[str]
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Filters a dictionary of property fields into valid and extra fields based on eligibility.
Parameters
----------
property_fields : dict
Dictionary of property fields where keys are field names and values are field values.
eligible_fields : set
Set of field names that are considered valid (eligible).
Returns
-------
tuple of dict
A tuple of two dictionaries:
- valid : dict
Contains fields that are both in `property_fields` and `eligible_fields`, and are not `None`.
- extra : dict
Contains fields that are in `property_fields` but not in `eligible_fields`, and are not `None`.
Examples
--------
>>> property_fields = {"field1": 10, "field2": None, "field3": "hello"}
>>> eligible_fields = {"field1", "field2"}
>>> valid, extra = field_filter(property_fields, eligible_fields)
>>> valid
{'field1': 10}
>>> extra
{'field3': 'hello'}
"""
valid = {k: v for k, v in property_fields.items() if k in eligible_fields if v is not None}
extra = {k: v for k, v in property_fields.items() if k not in eligible_fields if v is not None}

return valid, extra


def prepare_ext_field(valid_fields, extra_fields):
"""Cleanses the extra fields by removing any timeseries data."""
def prepare_ext_field(valid_fields: dict[str, Any], extra_fields: dict[str, Any]) -> dict[str, Any]:
"""Clean the extra fields by removing any time series data and adds the cleaned extra fields to `valid_fields`.
Parameters
----------
valid_fields : dict
Dictionary containing valid fields.
extra_fields : dict
Dictionary containing extra fields that may include data types not needed.
Returns
-------
dict
Updated valid_fields with cleansed extra fields under the "ext" key.
Examples
--------
>>> valid_fields = {"field1": 10, "field2": "hello"}
>>> extra_fields = {"field3": [1, 2, 3], "field4": 42}
>>> result = prepare_ext_field(valid_fields, extra_fields)
>>> result
{'field1': 10, 'field2': 'hello', 'ext': {'field4': 42}}
"""
if extra_fields:
# Implement any filtering of ext_data here
# logger.debug("Extra fields: {}", extra_fields)
# remove any non eligible datatypes from extra fields
# Filter to only include eligible data types
eligible_datatypes = [str, int, float, bool]
extra_fields = {k: v for k, v in extra_fields.items() if type(v) in eligible_datatypes}
valid_fields["ext"] = extra_fields
Expand All @@ -30,15 +80,50 @@ def prepare_ext_field(valid_fields, extra_fields):


def handle_leap_year_adjustment(data_file: pl.DataFrame) -> pl.DataFrame:
"""Duplicate feb 28th to feb 29th for leap years."""
"""Duplicate February 28th to February 29th for leap years.
Parameters
----------
data_file : pl.DataFrame
DataFrame containing timeseries data.
Returns
-------
pl.DataFrame
DataFrame adjusted for leap years.
Examples
--------
>>> df = pl.DataFrame({"date": ["2020-02-28"], "value": [1]})
>>> handle_leap_year_adjustment(df)
"""
feb_28 = data_file.slice(1392, 24)
before_feb_29 = data_file.slice(0, 1416)
after_feb_29 = data_file.slice(1416, len(data_file) - 1440)
return pl.concat([before_feb_29, feb_28, after_feb_29])


def fill_missing_timestamps(data_file: pl.DataFrame, hourly_time_index: pl.DataFrame) -> pl.DataFrame:
"""Add missing timestamps to data and forward fill nulls to complete a year."""
"""Add missing timestamps to data and forward fill nulls to complete a year.
Parameters
----------
data_file : pl.DataFrame
DataFrame containing timeseries data.
hourly_time_index : pl.DataFrame
DataFrame containing the hourly time index for the study year.
Returns
-------
pl.DataFrame
DataFrame with missing timestamps filled.
Examples
--------
>>> df = pl.DataFrame({"year": [2020], "month": [2], "day": [28], "hour": [0], "value": [1]})
>>> hourly_index = pl.DataFrame({"datetime": pl.date_range("2020-01-01", "2020-12-31", freq="1H")})
>>> fill_missing_timestamps(df, hourly_index)
"""
if "hour" in data_file.columns:
data_file = data_file.with_columns(
pl.datetime(pl.col("year"), pl.col("month"), pl.col("day"), pl.col("hour"))
Expand All @@ -54,27 +139,49 @@ def fill_missing_timestamps(data_file: pl.DataFrame, hourly_time_index: pl.DataF


def resample_data_to_hourly(data_file: pl.DataFrame) -> pl.DataFrame:
"""Resample data to hourly frequency from 30 minute data."""
data_file = data_file.with_columns((pl.col("hour") % 48).alias("hour"))
data_file = (
data_file.with_columns(
(
pl.datetime(
data_file["year"],
data_file["month"],
data_file["day"],
hour=data_file["hour"] // 2,
minute=(data_file["hour"] % 2) * 30,
)
).alias("timestamp")
)
.sort("timestamp")
.filter(pl.col("timestamp").is_not_null())
"""Resample data to hourly frequency from minute data.
Parameters
----------
data_file : pl.DataFrame
DataFrame containing timeseries data with minute intervals.
Returns
-------
pl.DataFrame
DataFrame resampled to hourly frequency.
Examples
--------
>>> df = pl.DataFrame(
... {
... "year": [2020, 2020, 2020, 2020],
... "month": [2, 2, 2, 2],
... "day": [28, 28, 28, 28],
... "hour": [0, 0, 1, 1],
... "minute": [0, 30, 0, 30], # Minute-level data
... "value": [1, 2, 3, 4],
... }
... )
>>> resampled_data = resample_data_to_hourly(df)
>>> resampled_data.shape[0]
# Expecting two rows: one for hour 0 and one for hour 1
"""
# Create a timestamp from year, month, day, hour, and minute
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"],
).alias("timestamp")
)

# Group by the hour and aggregate the values
return (
data_file.group_by_dynamic("timestamp", every="1h")
.agg([pl.col("value").mean().alias("value")])
.agg([pl.col("value").mean().alias("value")]) # Average of values for the hour
.with_columns(
pl.col("timestamp").dt.year().alias("year"),
pl.col("timestamp").dt.month().alias("month"),
Expand Down
4 changes: 3 additions & 1 deletion tests/data/pjm_2area_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,8 @@
0.074636836,
0.083141296,
0.077302516,
0.078747646
0.078747646,
0.210797797,
0.195670352
]
}
24 changes: 22 additions & 2 deletions tests/models/pjm.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""Script that creates simple 2-area pjm systems for testing."""

from datetime import datetime, timedelta
import pathlib
from datetime import datetime, timedelta

from infrasys.time_series_models import SingleTimeSeries

from r2x.api import System
from r2x.enums import ACBusTypes, PrimeMoversType
from r2x.enums import ACBusTypes, PrimeMoversType, ReserveDirection, ReserveType
from r2x.models.branch import AreaInterchange, Line, MonitoredLine
from r2x.models.core import ReserveMap
from r2x.models.generators import RenewableDispatch, ThermalStandard
from r2x.models.load import PowerLoad
from r2x.models.services import Reserve
from r2x.models.topology import ACBus, Area, LoadZone
from r2x.units import ActivePower, Percentage, Time, Voltage, ureg
from r2x.utils import read_json
Expand Down Expand Up @@ -161,4 +164,21 @@ def pjm_2area() -> System:
)
system.add_component(load_component)
system.add_time_series(ld_ts, load_component)

# Create reserve
reserve_map = ReserveMap(name="pjm_reserve_map")
reserve = Reserve(
name="SpinUp-pjm",
region=system.get_component(LoadZone, "LoadZone1"),
reserve_type=ReserveType.SPINNING,
vors=0.05,
duration=3600.0,
load_risk=0.5,
time_frame=3600,
direction=ReserveDirection.UP,
)
reserve_map.mapping[ReserveType.SPINNING.name].append(wind_01.name)
reserve_map.mapping[ReserveType.SPINNING.name].append(solar_pv_01.name)
system.add_components(reserve, reserve_map)

return system
12 changes: 12 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from r2x.enums import PrimeMoversType
from r2x.models import Generator, ACBus, Emission, HydroPumpedStorage, ThermalStandard
from r2x.models import MinMax
from r2x.units import EmissionRate, ureg


Expand Down Expand Up @@ -36,3 +37,14 @@ def test_pumped_hydro_generator():
assert isinstance(pumped_storage.bus, ACBus)
assert isinstance(pumped_storage.prime_mover_type, PrimeMoversType)
assert pumped_storage.prime_mover_type == PrimeMoversType.PS


def test_serialize_active_power_limits():
active_power_limits = MinMax(min=0, max=100)
generator = Generator(name="TestGEN", active_power_limits=active_power_limits)

output = generator.model_dump()
assert output["active_power_limits"] == {"min": 0, "max": 100}

output = generator.serialize_active_power_limits(active_power_limits)
assert output == {"min": 0, "max": 100}
Loading

0 comments on commit fe667d7

Please sign in to comment.