diff --git a/src/infrasys/function_data.py b/src/infrasys/function_data.py new file mode 100644 index 0000000..551f8e7 --- /dev/null +++ b/src/infrasys/function_data.py @@ -0,0 +1,172 @@ +"""Defines models for cost functions""" + +from infrasys import Component +from typing_extensions import Annotated +from pydantic import Field, model_validator +from pydantic.functional_validators import AfterValidator +from typing import NamedTuple, List +import numpy as np + + +class XYCoords(NamedTuple): + """Named tuple used to define (x,y) coordinates.""" + + x: float + y: float + + +class LinearFunctionData(Component): + """Data representation for linear cost function. + + Class to represent the underlying data of linear functions. Principally used for + the representation of cost functions `f(x) = proportional_term*x + constant_term`. + """ + + name: Annotated[str, Field(frozen=True)] = "" + proportional_term: Annotated[ + float, Field(description="the proportional term in the represented function") + ] + constant_term: Annotated[ + float, Field(description="the constant term in the represented function") + ] + + +class QuadraticFunctionData(Component): + """Data representation for quadratic cost functions. + + Class to represent the underlying data of quadratic functions. Principally used for the + representation of cost functions + `f(x) = quadratic_term*x^2 + proportional_term*x + constant_term`. + """ + + name: Annotated[str, Field(frozen=True)] = "" + quadratic_term: Annotated[ + float, Field(description="the quadratic term in the represented function") + ] + proportional_term: Annotated[ + float, Field(description="the proportional term in the represented function") + ] + constant_term: Annotated[ + float, Field(description="the constant term in the represented function") + ] + + +def validate_piecewise_linear_x(points: List[XYCoords]) -> List[XYCoords]: + """Validates the x data for PiecewiseLinearData class + + Function used to validate given x data for the PiecewiseLinearData class. + X data is checked to ensure there is at least two values of x, + which is the minimum required to generate a cost curve, and is + given in ascending order (e.g. [1, 2, 3], not [1, 3, 2]). + + Parameters + ---------- + points : List[XYCoords] + List of named tuples of (x,y) coordinates for cost function + + Returns + ---------- + points : List[XYCoords] + List of (x,y) data for cost function after successful validation. + """ + + x_coords = [p.x for p in points] + + if len(x_coords) < 2: + raise ValueError("Must specify at least two x-coordinates") + if not ( + x_coords == sorted(x_coords) + or (np.isnan(x_coords[0]) and x_coords[1:] == sorted(x_coords[1:])) + ): + raise ValueError(f"Piecewise x-coordinates must be ascending, got {x_coords}") + + return points + + +def validate_piecewise_step_x(x_coords: List[float]) -> List[float]: + """Validates the x data for PiecewiseStepData class + + Function used to validate given x data for the PiecewiseStepData class. + X data is checked to ensure there is at least two values of x, + which is the minimum required to generate a cost curve, and is + given in ascending order (e.g. [1, 2, 3], not [1, 3, 2]). + + Parameters + ---------- + x_coords : List[float] + List of x data for cost function. + + Returns + ---------- + x_coords : List[float] + List of x data for cost function after successful validation. + """ + + if len(x_coords) < 2: + raise ValueError("Must specify at least two x-coordinates") + if not ( + x_coords == sorted(x_coords) + or (np.isnan(x_coords[0]) and x_coords[1:] == sorted(x_coords[1:])) + ): + raise ValueError(f"Piecewise x-coordinates must be ascending, got {x_coords}") + + return x_coords + + +class PiecewiseLinearData(Component): + """Data representation for piecewise linear cost function. + + Class to represent piecewise linear data as a series of points: two points define one + segment, three points define two segments, etc. The curve starts at the first point given, + not the origin. Principally used for the representation of cost functions where the points + store quantities (x, y), such as (MW, USD/h). + """ + + name: Annotated[str, Field(frozen=True)] = "" + points: Annotated[ + List[XYCoords], + AfterValidator(validate_piecewise_linear_x), + Field(description="list of (x,y) points that define the function"), + ] + + +class PiecewiseStepData(Component): + """Data representation for piecewise step cost function. + + Class to represent a step function as a series of endpoint x-coordinates and segment + y-coordinates: two x-coordinates and one y-coordinate defines a single segment, three + x-coordinates and two y-coordinates define two segments, etc. This can be useful to + represent the derivative of a `PiecewiseLinearData`, where the y-coordinates of this + step function represent the slopes of that piecewise linear function. + Principally used for the representation of cost functions where the points store + quantities (x, dy/dx), such as (MW, USD/MWh). + """ + + name: Annotated[str, Field(frozen=True)] = "" + x_coords: Annotated[ + List[float], + Field(description="the x-coordinates of the endpoints of the segments"), + ] + y_coords: Annotated[ + List[float], + Field( + description="the y-coordinates of the segments: `y_coords[1]` is the y-value \ + between `x_coords[0]` and `x_coords[1]`, etc. Must have one fewer elements than `x_coords`." + ), + ] + + @model_validator(mode="after") + def validate_piecewise_xy(self): + """Method to validate the x and y data for PiecewiseStepData class + + Model validator used to validate given data for the PiecewiseStepData class. + Calls `validate_piecewise_step_x` to check if `x_coords` is valid, then checks if + the length of `y_coords` is exactly one less than `x_coords`, which is necessary + to define the cost functions correctly. + """ + validate_piecewise_step_x(self.x_coords) + + if len(self.y_coords) != len(self.x_coords) - 1: + raise ValueError("Must specify one fewer y-coordinates than x-coordinates") + + return self diff --git a/tests/test_function_data.py b/tests/test_function_data.py new file mode 100644 index 0000000..3888373 --- /dev/null +++ b/tests/test_function_data.py @@ -0,0 +1,50 @@ +from infrasys.function_data import PiecewiseStepData, PiecewiseLinearData, XYCoords +import pytest + + +def test_xycoords(): + test_xy = XYCoords(x=1.0, y=2.0) + + # Checking associated types + assert isinstance(test_xy, XYCoords) + + assert isinstance(test_xy.x, float) + + assert isinstance(test_xy.y, float) + + +def test_piecewise_linear(): + # Check validation for minimum x values + test_coords = [XYCoords(1.0, 2.0)] + + with pytest.raises(ValueError): + PiecewiseLinearData(points=test_coords) + + # Check validation for ascending x values + test_coords = [XYCoords(1.0, 2.0), XYCoords(4.0, 3.0), XYCoords(3.0, 4.0)] + + with pytest.raises(ValueError): + PiecewiseLinearData(points=test_coords) + + +def test_piecewise_step(): + # Check minimum x values + test_x = [2] + test_y = [1] + + with pytest.raises(ValueError): + PiecewiseStepData(x_coords=test_x, y_coords=test_y) + + # Check ascending x values + test_x = [1, 4, 3] + test_y = [2, 4] + + with pytest.raises(ValueError): + PiecewiseStepData(x_coords=test_x, y_coords=test_y) + + # Check length of x and y lists + test_x = [1, 2, 3] + test_y = [2, 4, 3] + + with pytest.raises(ValueError): + PiecewiseStepData(x_coords=test_x, y_coords=test_y)