diff --git a/demo_energiedelen.ipynb b/demo_energiedelen.ipynb index f7ecd1b..56fda2c 100644 --- a/demo_energiedelen.ipynb +++ b/demo_energiedelen.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "from openenergyid import TimeSeries\n", + "from openenergyid import TimeDataFrame\n", "from openenergyid.energysharing import EnergySharingInput, EnergySharingOutput, calculate, CalculationMethod" ] }, @@ -386,9 +386,9 @@ "# This helps with validation and serialization\n", "\n", "input = EnergySharingInput.model_construct(\n", - " gross_injection=TimeSeries.from_pandas(gross_injection),\n", - " gross_offtake=TimeSeries.from_pandas(gross_offtake),\n", - " key=TimeSeries.from_pandas(key),\n", + " gross_injection=TimeDataFrame.from_pandas(gross_injection),\n", + " gross_offtake=TimeDataFrame.from_pandas(gross_offtake),\n", + " key=TimeDataFrame.from_pandas(key),\n", ")\n", "\n", "df = input.data_frame()" @@ -449,18 +449,18 @@ " \n", " \n", " 2024-01-01 12:00:00+01:00\n", - " 70.0\n", - " 10.0\n", - " 20.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 30.0\n", - " 22.0\n", - " 30.0\n", - " 18.0\n", - " 20.0\n", + " 70\n", + " 10\n", + " 20\n", + " 0\n", + " 0\n", + " 0\n", + " 0\n", + " 30\n", + " 22\n", + " 30\n", + " 18\n", + " 20\n", " 0.0\n", " 0.1\n", " 0.225\n", @@ -470,18 +470,18 @@ " \n", " \n", " 2024-01-01 12:15:00+01:00\n", - " 20.0\n", - " 10.0\n", - " 30.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 20.0\n", - " 20.0\n", - " 25.0\n", - " 17.0\n", - " 22.0\n", + " 20\n", + " 10\n", + " 30\n", + " 0\n", + " 0\n", + " 0\n", + " 0\n", + " 20\n", + " 20\n", + " 25\n", + " 17\n", + " 22\n", " 0.0\n", " 0.1\n", " 0.225\n", @@ -491,18 +491,18 @@ " \n", " \n", " 2024-01-01 12:30:00+01:00\n", - " 100.0\n", - " 10.0\n", - " 40.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 10.0\n", - " 30.0\n", - " 35.0\n", - " 19.0\n", - " 24.0\n", + " 100\n", + " 10\n", + " 40\n", + " 0\n", + " 0\n", + " 0\n", + " 0\n", + " 10\n", + " 30\n", + " 35\n", + " 19\n", + " 24\n", " 0.0\n", " 0.1\n", " 0.225\n", @@ -512,18 +512,18 @@ " \n", " \n", " 2024-01-01 12:45:00+01:00\n", - " 0.0\n", - " 10.0\n", - " 50.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 0.0\n", - " 40.0\n", - " 10.0\n", - " 25.0\n", - " 26.0\n", + " 0\n", + " 10\n", + " 50\n", + " 0\n", + " 0\n", + " 0\n", + " 0\n", + " 0\n", + " 40\n", + " 10\n", + " 25\n", + " 26\n", " 0.0\n", " 0.1\n", " 0.225\n", @@ -536,26 +536,26 @@ "" ], "text/plain": [ - " Gross Injection \\\n", - " P1 P2 P3 P4 P5 P6 \n", - "2024-01-01 12:00:00+01:00 70.0 10.0 20.0 0.0 0.0 0.0 \n", - "2024-01-01 12:15:00+01:00 20.0 10.0 30.0 0.0 0.0 0.0 \n", - "2024-01-01 12:30:00+01:00 100.0 10.0 40.0 0.0 0.0 0.0 \n", - "2024-01-01 12:45:00+01:00 0.0 10.0 50.0 0.0 0.0 0.0 \n", + " Gross Injection Gross Offtake \\\n", + " P1 P2 P3 P4 P5 P6 P1 P2 \n", + "2024-01-01 12:00:00+01:00 70 10 20 0 0 0 0 30 \n", + "2024-01-01 12:15:00+01:00 20 10 30 0 0 0 0 20 \n", + "2024-01-01 12:30:00+01:00 100 10 40 0 0 0 0 10 \n", + "2024-01-01 12:45:00+01:00 0 10 50 0 0 0 0 0 \n", "\n", - " Gross Offtake Key \\\n", - " P1 P2 P3 P4 P5 P6 P1 \n", - "2024-01-01 12:00:00+01:00 0.0 30.0 22.0 30.0 18.0 20.0 0.0 \n", - "2024-01-01 12:15:00+01:00 0.0 20.0 20.0 25.0 17.0 22.0 0.0 \n", - "2024-01-01 12:30:00+01:00 0.0 10.0 30.0 35.0 19.0 24.0 0.0 \n", - "2024-01-01 12:45:00+01:00 0.0 0.0 40.0 10.0 25.0 26.0 0.0 \n", + " Key \\\n", + " P3 P4 P5 P6 P1 P2 P3 P4 P5 \n", + "2024-01-01 12:00:00+01:00 22 30 18 20 0.0 0.1 0.225 0.225 0.225 \n", + "2024-01-01 12:15:00+01:00 20 25 17 22 0.0 0.1 0.225 0.225 0.225 \n", + "2024-01-01 12:30:00+01:00 30 35 19 24 0.0 0.1 0.225 0.225 0.225 \n", + "2024-01-01 12:45:00+01:00 40 10 25 26 0.0 0.1 0.225 0.225 0.225 \n", "\n", - " \n", - " P2 P3 P4 P5 P6 \n", - "2024-01-01 12:00:00+01:00 0.1 0.225 0.225 0.225 0.225 \n", - "2024-01-01 12:15:00+01:00 0.1 0.225 0.225 0.225 0.225 \n", - "2024-01-01 12:30:00+01:00 0.1 0.225 0.225 0.225 0.225 \n", - "2024-01-01 12:45:00+01:00 0.1 0.225 0.225 0.225 0.225 " + " \n", + " P6 \n", + "2024-01-01 12:00:00+01:00 0.225 \n", + "2024-01-01 12:15:00+01:00 0.225 \n", + "2024-01-01 12:30:00+01:00 0.225 \n", + "2024-01-01 12:45:00+01:00 0.225 " ] }, "execution_count": 9, @@ -1155,6 +1155,12 @@ "text": [ "{\n", " \"netInjection\": {\n", + " \"index\": [\n", + " \"2024-01-01T12:00:00+01:00\",\n", + " \"2024-01-01T12:15:00+01:00\",\n", + " \"2024-01-01T12:30:00+01:00\",\n", + " \"2024-01-01T12:45:00+01:00\"\n", + " ],\n", " \"columns\": [\n", " \"P1\",\n", " \"P2\",\n", @@ -1163,12 +1169,6 @@ " \"P5\",\n", " \"P6\"\n", " ],\n", - " \"index\": [\n", - " \"2024-01-01T12:00:00+01:00\",\n", - " \"2024-01-01T12:15:00+01:00\",\n", - " \"2024-01-01T12:30:00+01:00\",\n", - " \"2024-01-01T12:45:00+01:00\"\n", - " ],\n", " \"data\": [\n", " [\n", " 0.0,\n", @@ -1205,6 +1205,12 @@ " ]\n", " },\n", " \"netOfftake\": {\n", + " \"index\": [\n", + " \"2024-01-01T12:00:00+01:00\",\n", + " \"2024-01-01T12:15:00+01:00\",\n", + " \"2024-01-01T12:30:00+01:00\",\n", + " \"2024-01-01T12:45:00+01:00\"\n", + " ],\n", " \"columns\": [\n", " \"P1\",\n", " \"P2\",\n", @@ -1213,12 +1219,6 @@ " \"P5\",\n", " \"P6\"\n", " ],\n", - " \"index\": [\n", - " \"2024-01-01T12:00:00+01:00\",\n", - " \"2024-01-01T12:15:00+01:00\",\n", - " \"2024-01-01T12:30:00+01:00\",\n", - " \"2024-01-01T12:45:00+01:00\"\n", - " ],\n", " \"data\": [\n", " [\n", " 0.0,\n", @@ -1255,6 +1255,12 @@ " ]\n", " },\n", " \"sharedEnergy\": {\n", + " \"index\": [\n", + " \"2024-01-01T12:00:00+01:00\",\n", + " \"2024-01-01T12:15:00+01:00\",\n", + " \"2024-01-01T12:30:00+01:00\",\n", + " \"2024-01-01T12:45:00+01:00\"\n", + " ],\n", " \"columns\": [\n", " \"P1\",\n", " \"P2\",\n", @@ -1263,12 +1269,6 @@ " \"P5\",\n", " \"P6\"\n", " ],\n", - " \"index\": [\n", - " \"2024-01-01T12:00:00+01:00\",\n", - " \"2024-01-01T12:15:00+01:00\",\n", - " \"2024-01-01T12:30:00+01:00\",\n", - " \"2024-01-01T12:45:00+01:00\"\n", - " ],\n", " \"data\": [\n", " [\n", " 0.0,\n", diff --git a/demo_energyid_download.ipynb b/demo_energyid_download.ipynb index 69f0529..61bb451 100644 --- a/demo_energyid_download.ipynb +++ b/demo_energyid_download.ipynb @@ -24,7 +24,7 @@ "source": [ "import json\n", "import energyid\n", - "from openenergyid import TimeSeries, const" + "from openenergyid import TimeDataFrame, const" ] }, { @@ -98,9 +98,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Rename to standard metric names used in the Open Energy Library\n", - "\n", - "energy_balance.columns = [const.ELECTRICITY_DELIVERED, const.ELECTRICITY_PRODUCED, const.ELECTRICITY_EXPORTED]" + "energy_balance" ] }, { @@ -109,7 +107,9 @@ "metadata": {}, "outputs": [], "source": [ - "energy_balance.index.name = None" + "# Rename to standard metric names used in the Open Energy Library\n", + "\n", + "energy_balance.columns = [const.ELECTRICITY_DELIVERED, const.ELECTRICITY_PRODUCED, const.ELECTRICITY_EXPORTED]" ] }, { @@ -118,7 +118,7 @@ "metadata": {}, "outputs": [], "source": [ - "series = TimeSeries.from_pandas(energy_balance)" + "data = TimeDataFrame.from_pandas(energy_balance)" ] }, { @@ -127,7 +127,7 @@ "metadata": {}, "outputs": [], "source": [ - "series.to_json(path='data/energy_balance_PT15M.json', indent=2)" + "data.to_json(path='data/energy_balance_PT15M.json', indent=2)" ] } ], diff --git a/openenergyid/__init__.py b/openenergyid/__init__.py index 4fd4a2b..a49539a 100644 --- a/openenergyid/__init__.py +++ b/openenergyid/__init__.py @@ -3,6 +3,6 @@ __version__ = "0.1.10" from .enums import Granularity -from .models import TimeSeries +from .models import TimeDataFrame, TimeSeries -__all__ = ["Granularity", "TimeSeries"] +__all__ = ["Granularity", "TimeDataFrame", "TimeSeries"] diff --git a/openenergyid/energysharing/models.py b/openenergyid/energysharing/models.py index 4835f14..084a2fa 100644 --- a/openenergyid/energysharing/models.py +++ b/openenergyid/energysharing/models.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field import pandas as pd -from openenergyid import TimeSeries +from openenergyid import TimeDataFrame from .data_formatting import create_multi_index_input_frame from .const import NET_INJECTION, NET_OFFTAKE, SHARED_ENERGY @@ -22,9 +22,9 @@ class CalculationMethod(Enum): class EnergySharingInput(BaseModel): """Input data for energy sharing.""" - gross_injection: Annotated[TimeSeries, Field(alias="grossInjection")] - gross_offtake: Annotated[TimeSeries, Field(alias="grossOfftake")] - key: Annotated[TimeSeries, Field(alias="key")] + gross_injection: Annotated[TimeDataFrame, Field(alias="grossInjection")] + gross_offtake: Annotated[TimeDataFrame, Field(alias="grossOfftake")] + key: Annotated[TimeDataFrame, Field(alias="key")] timezone: str = Field(alias="timeZone", default="Europe/Brussels") def data_frame(self) -> pd.DataFrame: @@ -41,15 +41,15 @@ def data_frame(self) -> pd.DataFrame: class EnergySharingOutput(BaseModel): """Output data for energy sharing.""" - net_injection: TimeSeries = Field(alias="netInjection") - net_offtake: TimeSeries = Field(alias="netOfftake") - shared_energy: TimeSeries = Field(alias="sharedEnergy") + net_injection: TimeDataFrame = Field(alias="netInjection") + net_offtake: TimeDataFrame = Field(alias="netOfftake") + shared_energy: TimeDataFrame = Field(alias="sharedEnergy") @classmethod def from_calculation_result(cls, result: pd.DataFrame) -> "EnergySharingOutput": """Create an output model from a calculation result.""" return cls.model_construct( - net_injection=TimeSeries.from_pandas(result[NET_INJECTION]), - net_offtake=TimeSeries.from_pandas(result[NET_OFFTAKE]), - shared_energy=TimeSeries.from_pandas(result[SHARED_ENERGY]), + net_injection=TimeDataFrame.from_pandas(result[NET_INJECTION]), + net_offtake=TimeDataFrame.from_pandas(result[NET_OFFTAKE]), + shared_energy=TimeDataFrame.from_pandas(result[SHARED_ENERGY]), ) diff --git a/openenergyid/models.py b/openenergyid/models.py index 32c8828..b343324 100644 --- a/openenergyid/models.py +++ b/openenergyid/models.py @@ -1,67 +1,119 @@ """Data models for the Open Energy ID.""" import datetime as dt -from typing import Optional, overload +from typing import Optional, overload, Union + +try: + from typing import Self +except ImportError: + from typing_extensions import Self import pandas as pd from pydantic import BaseModel -class TimeSeries(BaseModel): - """Time series data.""" +class TimeSeriesBase(BaseModel): + """Pydantic base model for time series data.""" - columns: list[str] index: list[dt.datetime] - data: list[list[float]] @classmethod - def from_pandas(cls, data: pd.DataFrame) -> "TimeSeries": - """Create a MultiVariableRegressionInputFrame from a pandas DataFrame.""" - return cls.model_validate(data.to_dict(orient="split")) + def from_pandas(cls, data: Union[pd.Series, pd.DataFrame]) -> Self: + """Create from a Pandas Object.""" + raise NotImplementedError - def to_pandas(self, timezone: str = "UTC") -> pd.DataFrame: - """Convert the MultiVariableRegressionInputFrame to a pandas DataFrame.""" - frame = pd.DataFrame(self.data, columns=self.columns, index=self.index) - frame.index = pd.to_datetime(frame.index, utc=True) - return frame.tz_convert(timezone) + def to_pandas(self, timezone: str = "UTC") -> Union[pd.Series, pd.DataFrame]: + """Convert to a Pandas Object.""" + raise NotImplementedError @overload def to_json(self, path: None = None, **kwargs) -> str: - ... + """Dump to a JSON string.""" @overload def to_json(self, path: str, **kwargs) -> None: - ... + """Dump to a JSON file.""" def to_json(self, path: Optional[str] = None, **kwargs) -> Optional[str]: - """Save the TimeSeries to a JSON file or return as string.""" + """Dump to a JSON string or file.""" if path is None: return self.model_dump_json(**kwargs) - else: - encoding = kwargs.pop("encoding", "UTF-8") - with open(path, "w", encoding=encoding) as file: - file.write(self.model_dump_json(**kwargs)) + encoding = kwargs.pop("encoding", "UTF-8") + with open(path, "w", encoding=encoding) as file: + file.write(self.model_dump_json(**kwargs)) + return None @overload @classmethod - def from_json(cls, string: str, **kwargs) -> "TimeSeries": - ... + def from_json(cls, string: str, **kwargs) -> Self: + """Load from a JSON string.""" @overload @classmethod - def from_json(cls, path: str, **kwargs) -> "TimeSeries": - ... + def from_json(cls, path: str, **kwargs) -> Self: + """Load from a JSON file.""" @classmethod - def from_json( - cls, string: Optional[str] = None, path: Optional[str] = None, **kwargs - ) -> "TimeSeries": - """Load the TimeSeries from a JSON file or string.""" + def from_json(cls, string: Optional[str] = None, path: Optional[str] = None, **kwargs) -> Self: + """Load from a JSON file or string.""" if string: return cls.model_validate_json(string, **kwargs) - elif path: + if path: encoding = kwargs.pop("encoding", "UTF-8") with open(path, "r", encoding=encoding) as file: return cls.model_validate_json(file.read(), **kwargs) - else: - raise ValueError("Either string or path must be provided.") + raise ValueError("Either string or path must be provided.") + + +class TimeSeries(TimeSeriesBase): + """Time series data with a single column.""" + + name: Union[str, None] = None + data: list[float] + + @classmethod + def from_pandas(cls, data: pd.Series) -> Self: + """Create from a Pandas Series.""" + return cls.model_construct(name=data.name, data=data.tolist(), index=data.index.tolist()) + + def to_pandas(self, timezone: str = "UTC") -> pd.Series: + """Convert to a Pandas Series.""" + series = pd.Series(self.data, name=self.name, index=self.index) + series.index = pd.to_datetime(series.index, utc=True) + return series.tz_convert(timezone) + + +class TimeDataFrame(TimeSeriesBase): + """Time series data with multiple columns.""" + + columns: list[str] + data: list[list[float]] + + @classmethod + def from_pandas(cls, data: pd.DataFrame) -> Self: + """Create from a Pandas DataFrame.""" + return cls.model_construct( + columns=data.columns.tolist(), data=data.values.tolist(), index=data.index.tolist() + ) + + def to_pandas(self, timezone: str = "UTC") -> pd.DataFrame: + """Convert to a Pandas DataFrame.""" + frame = pd.DataFrame(self.data, columns=self.columns, index=self.index) + frame.index = pd.to_datetime(frame.index, utc=True) + return frame.tz_convert(timezone) + + @classmethod + def from_timeseries(cls, data: list[TimeSeries]) -> Self: + """Create from a list of TimeSeries objects.""" + return cls.model_construct( + columns=[series.name for series in data], + data=[series.data for series in data], + index=data[0].index, + ) + + def to_timeseries(self) -> list[TimeSeries]: + """Convert to a list of TimeSeries objects.""" + return [ + TimeSeries(name=column, data=column_data, index=self.index) + for column, column_data in zip(self.columns, self.data) + ] diff --git a/openenergyid/mvlr/models.py b/openenergyid/mvlr/models.py index 34ac7a5..bd067c7 100644 --- a/openenergyid/mvlr/models.py +++ b/openenergyid/mvlr/models.py @@ -6,7 +6,7 @@ import statsmodels.formula.api as fm from openenergyid.enums import Granularity -from openenergyid.models import TimeSeries +from openenergyid.models import TimeDataFrame from .mvlr import MultiVariableLinearRegression @@ -66,7 +66,7 @@ class MultiVariableRegressionInput(BaseModel): alias="independentVariables", min_length=1 ) dependent_variable: str = Field(alias="dependentVariable") - frame: TimeSeries + frame: TimeDataFrame granularities: list[Granularity] allow_negative_predictions: bool = Field(alias="allowNegativePredictions", default=False) validation_parameters: ValidationParameters = Field( @@ -195,7 +195,7 @@ class MultiVariableRegressionResult(BaseModel): prob_f_stat: float = Field(ge=0, le=1, alias="probFStat") intercept: IndependentVariableResult granularity: Granularity - frame: TimeSeries + frame: TimeDataFrame model_config = ConfigDict(populate_by_name=True) @@ -225,5 +225,5 @@ def from_mvlr(cls, mvlr: MultiVariableLinearRegression) -> "MultiVariableRegress prob_f_stat=mvlr.fit.f_pvalue, intercept=IndependentVariableResult.from_fit(mvlr.fit, "Intercept"), granularity=mvlr.granularity, - frame=TimeSeries.from_pandas(frame), + frame=TimeDataFrame.from_pandas(frame), )