openenergyid 0.1.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of openenergyid might be problematic. Click here for more details.

@@ -0,0 +1,8 @@
1
+ """Open Energy ID Python SDK."""
2
+
3
+ __version__ = "0.1.21"
4
+
5
+ from .enums import Granularity
6
+ from .models import TimeDataFrame, TimeSeries
7
+
8
+ __all__ = ["Granularity", "TimeDataFrame", "TimeSeries"]
@@ -0,0 +1,15 @@
1
+ """Baseload analysis package for power consumption data."""
2
+
3
+ from .models import PowerReadingSchema, PowerSeriesSchema, BaseloadResultSchema
4
+ from .analysis import BaseloadAnalyzer
5
+ from .exceptions import InsufficientDataError, InvalidDataError
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "BaseloadAnalyzer",
10
+ "InsufficientDataError",
11
+ "InvalidDataError",
12
+ "PowerReadingSchema",
13
+ "PowerSeriesSchema",
14
+ "BaseloadResultSchema",
15
+ ]
@@ -0,0 +1,173 @@
1
+ """Baseload Power Consumption Analysis Module
2
+
3
+ This module provides tools for analyzing electrical power consumption patterns to identify
4
+ and quantify baseload - the continuous background power usage in electrical systems.
5
+ It uses sophisticated time-series analysis to detect consistent minimum power draws
6
+ that represent always-on devices and systems.
7
+ """
8
+
9
+ import polars as pl
10
+
11
+
12
+ class BaseloadAnalyzer:
13
+ """Analyzes power consumption data to determine baseload characteristics.
14
+
15
+ The BaseloadAnalyzer helps identify the minimum continuous power consumption in
16
+ an electrical system by analyzing regular energy readings. It uses a statistical
17
+ approach to determine baseload, which represents power used by devices that run
18
+ continuously (like refrigerators, standby electronics, or network equipment).
19
+
20
+ The analyzer works by:
21
+ 1. Converting 15-minute energy readings to instantaneous power values
22
+ 2. Analyzing daily patterns to identify consistent minimum usage
23
+ 3. Aggregating results into configurable time periods
24
+
25
+ Parameters
26
+ ----------
27
+ quantile : float, default=0.05
28
+ Defines what portion of lowest daily readings to consider as baseload.
29
+ The default 0.05 (5%) corresponds to roughly 72 minutes of lowest
30
+ consumption per day, which helps filter out brief power dips while
31
+ capturing true baseload patterns.
32
+
33
+ timezone : str
34
+ Timezone for analysis. All timestamps will be converted to this timezone
35
+ to ensure correct daily boundaries and consistent reporting periods.
36
+
37
+ Example Usage
38
+ ------------
39
+ >>> analyzer = BaseloadAnalyzer(quantile=0.05)
40
+ >>> power_data = analyzer.prepare_power_seriespolars(energy_readings)
41
+ >>> hourly_analysis = analyzer.analyze(power_data, "1h")
42
+ >>> monthly_analysis = analyzer.analyze(power_data, "1mo")
43
+ """
44
+
45
+ def __init__(self, timezone: str, quantile: float = 0.05):
46
+ self.quantile = quantile
47
+ self.timezone = timezone
48
+
49
+ def prepare_power_seriespolars(self, energy_lf: pl.LazyFrame) -> pl.LazyFrame:
50
+ """Converts energy readings into a power consumption time series.
51
+
52
+ Transforms 15-minute energy readings (kilowatt-hours) into instantaneous
53
+ power readings (watts) while handling timezone conversion.
54
+
55
+ Parameters
56
+ ----------
57
+ energy_lf : pl.LazyFrame
58
+ Input energy data with columns:
59
+ - timestamp: Datetime with timezone (e.g. "2023-01-01T00:00:00+01:00")
60
+ - total: Energy readings in kilowatt-hours (kWh)
61
+
62
+ Returns
63
+ -------
64
+ pl.LazyFrame
65
+ Power series with columns:
66
+ - timestamp: Timezone-adjusted timestamps
67
+ - power: Power readings in watts
68
+
69
+ Notes
70
+ -----
71
+ The conversion from kWh/15min to watts uses the formula:
72
+ watts = kWh * 4000
73
+ where:
74
+ - Multiply by 4 to convert from 15-minute to hourly rate
75
+ - Multiply by 1000 to convert from kilowatts to watts
76
+ """
77
+ return (
78
+ energy_lf.with_columns(
79
+ [
80
+ # Convert timezone
81
+ pl.col("timestamp")
82
+ .dt.replace_time_zone("UTC")
83
+ .dt.convert_time_zone(self.timezone)
84
+ .alias("timestamp"),
85
+ # Convert to watts and clip negative values
86
+ (pl.col("total") * 4000).clip(0).alias("power"),
87
+ ]
88
+ )
89
+ .drop("total")
90
+ .sort("timestamp")
91
+ )
92
+
93
+ def analyze(self, power_lf: pl.LazyFrame, reporting_granularity: str = "1h") -> pl.LazyFrame:
94
+ """Analyze power consumption data to calculate baseload and total energy metrics.
95
+
96
+ Takes power readings (in watts) with 15-minute intervals and calculates:
97
+ - Daily baseload power using a percentile threshold
98
+ - Energy consumption from baseload vs total consumption
99
+ - Average power metrics
100
+
101
+ The analysis happens in three steps:
102
+ 1. Calculate the daily baseload power level using the configured percentile
103
+ 2. Join this daily baseload with the original power readings
104
+ 3. Aggregate the combined data into the requested reporting periods
105
+
106
+ Parameters
107
+ ----------
108
+ power_lf : pl.LazyFrame
109
+ Power consumption data with columns:
110
+ - timestamp: Datetime in configured timezone
111
+ - power: Power readings in watts
112
+
113
+ reporting_granularity : str, default="1h"
114
+ Time period for aggregating results. Must be a valid Polars interval string
115
+ like "1h", "1d", "1mo" etc.
116
+
117
+ Returns
118
+ -------
119
+ pl.LazyFrame
120
+ Analysis results with metrics per reporting period:
121
+ - timestamp: Start of reporting period
122
+ - consumption_due_to_baseload_in_kilowatthour: Baseload energy
123
+ - total_consumption_in_kilowatthour: Total energy
124
+ - consumption_not_due_to_baseload_in_kilowatthour: Non-baseload energy
125
+ - average_daily_baseload_in_watt: Average baseload power level
126
+ - average_power_in_watt: Average total power
127
+ - baseload_ratio: Fraction of energy from baseload
128
+ """
129
+ # Step 1: Calculate the daily baseload level
130
+ # Group power readings by day and find the threshold power level that represents baseload
131
+ daily_baseload = power_lf.group_by_dynamic("timestamp", every="1d").agg(
132
+ pl.col("power").quantile(self.quantile).alias("daily_baseload")
133
+ )
134
+
135
+ # Step 2 & 3: Join baseload data and aggregate metrics
136
+ return (
137
+ # Join the daily baseload level with original power readings
138
+ # Using asof join since baseload changes daily but readings are every 15min
139
+ power_lf.join_asof(daily_baseload, on="timestamp")
140
+ # Group into requested reporting periods
141
+ .group_by_dynamic("timestamp", every=reporting_granularity)
142
+ .agg(
143
+ [
144
+ # Energy calculations:
145
+ # Each 15min power reading (watts) represents 0.25 hours
146
+ # Convert to kWh: watts * 0.25h * (1kW/1000W)
147
+ (pl.col("daily_baseload").sum() * 0.25 / 1000).alias(
148
+ "consumption_due_to_baseload_in_kilowatthour"
149
+ ),
150
+ (pl.col("power").sum() * 0.25 / 1000).alias(
151
+ "total_consumption_in_kilowatthour"
152
+ ),
153
+ # Average power levels during the period
154
+ pl.col("daily_baseload").mean().alias("average_daily_baseload_in_watt"),
155
+ pl.col("power").mean().alias("average_power_in_watt"),
156
+ ]
157
+ )
158
+ # Calculate derived metrics
159
+ .with_columns(
160
+ [
161
+ # Energy consumed above baseload level
162
+ (
163
+ pl.col("total_consumption_in_kilowatthour")
164
+ - pl.col("consumption_due_to_baseload_in_kilowatthour")
165
+ ).alias("consumption_not_due_to_baseload_in_kilowatthour"),
166
+ # What fraction of total energy was from baseload
167
+ (
168
+ pl.col("consumption_due_to_baseload_in_kilowatthour")
169
+ / pl.col("total_consumption_in_kilowatthour")
170
+ ).alias("baseload_ratio"),
171
+ ]
172
+ )
173
+ )
@@ -0,0 +1,9 @@
1
+ """Custom exceptions for baseload analysis."""
2
+
3
+
4
+ class InsufficientDataError(Exception):
5
+ """Raised when input data doesn't meet minimum requirements."""
6
+
7
+
8
+ class InvalidDataError(Exception):
9
+ """Raised when input data is invalid or corrupt."""
@@ -0,0 +1,31 @@
1
+ import pandera.polars as pa
2
+ from pandera.engines.polars_engine import DateTime
3
+
4
+
5
+ class PowerReadingSchema(pa.DataFrameModel):
6
+ """Validates input energy readings"""
7
+
8
+ timestamp: DateTime = pa.Field()
9
+ total: float = pa.Field(ge=0)
10
+
11
+ class Config:
12
+ coerce = True
13
+
14
+
15
+ class PowerSeriesSchema(pa.DataFrameModel):
16
+ """Validates converted power series"""
17
+
18
+ timestamp: DateTime = pa.Field()
19
+ power: float = pa.Field(ge=0)
20
+
21
+
22
+ class BaseloadResultSchema(pa.DataFrameModel):
23
+ """Validates analysis results"""
24
+
25
+ timestamp: DateTime = pa.Field()
26
+ consumption_due_to_baseload_in_kilowatthour: float = pa.Field(ge=0)
27
+ total_consumption_in_kilowatthour: float = pa.Field(ge=0)
28
+ average_daily_baseload_in_watt: float = pa.Field(ge=0)
29
+ average_power_in_watt: float = pa.Field(ge=0)
30
+ consumption_not_due_to_baseload_in_kilowatthour: float
31
+ baseload_ratio: float = pa.Field(ge=0, le=2)
@@ -0,0 +1,6 @@
1
+ """Power Offtake peak analysis module."""
2
+
3
+ from .models import CapacityInput, CapacityOutput, PeakDetail
4
+ from .main import CapacityAnalysis
5
+
6
+ __all__ = ["CapacityInput", "CapacityAnalysis", "CapacityOutput", "PeakDetail"]
@@ -0,0 +1,102 @@
1
+ """Main module for capacity analysis."""
2
+
3
+ import datetime as dt
4
+ import typing
5
+ import pandas as pd
6
+ import pandera.typing as pdt
7
+
8
+
9
+ class CapacityAnalysis:
10
+ """
11
+ A class for performing capacity analysis on a given dataset.
12
+
13
+ Attributes:
14
+ data (CapacityInput): The input data for capacity analysis.
15
+ threshold (float): The value above which a peak is considered significant.
16
+ window (str): The window size for grouping data before finding peaks. Defaults to "MS" (month start).
17
+ x_padding (int): The padding to apply on the x-axis for visualization purposes.
18
+
19
+ Methods:
20
+ find_peaks(): Identifies peaks in the data based on the specified threshold and window.
21
+ find_peaks_with_surroundings(num_peaks=10): Finds peaks along with their surrounding data points.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ data: pdt.Series,
27
+ threshold: float = 2.5,
28
+ window: str = "MS", # Default to month start
29
+ x_padding: int = 4,
30
+ ):
31
+ """
32
+ Constructs all the necessary attributes for the CapacityAnalysis object.
33
+
34
+ Parameters:
35
+ data (CapacityInput): Localized Pandas Series containing power measurements.
36
+ threshold (float): The value above which a peak is considered significant. Defaults to 2.5.
37
+ window (str): The window size for grouping data before finding peaks. Defaults to "MS" (month start).
38
+ x_padding (int): The padding to apply on the x-axis for visualization purposes. Defaults to 4.
39
+ """
40
+
41
+ self.data = data
42
+ self.threshold = threshold
43
+ self.window = window
44
+ self.x_padding = x_padding
45
+
46
+ def find_peaks(self) -> pd.Series:
47
+ """
48
+ Identifies peaks in the data based on the specified threshold and window.
49
+
50
+ Returns:
51
+ pd.Series: A Pandas Series containing the peaks
52
+ """
53
+ # Group by the specified window (default is month start)
54
+ grouped = self.data.groupby(pd.Grouper(freq=self.window))
55
+
56
+ # Find the index (timestamp) of the maximum value in each group
57
+ peak_indices = grouped.idxmax()
58
+
59
+ # Get the corresponding peak values
60
+ peaks = self.data.loc[peak_indices][self.data > self.threshold]
61
+ return peaks
62
+
63
+ def find_peaks_with_surroundings(
64
+ self, num_peaks: int = 10
65
+ ) -> list[tuple[dt.datetime, float, pd.Series]]:
66
+ """
67
+ Finds peaks along with their surrounding data points.
68
+
69
+ Parameters:
70
+ num_peaks (int): The number of peaks to find. Defaults to 10.
71
+
72
+ Returns:
73
+ List[tuple[dt.datetime,float,pd.Series]]: A list of tuples containing peak time, peak value, and surrounding data.
74
+ """
75
+ peaks = self.data.nlargest(num_peaks * 2)
76
+ peaks = peaks[peaks > self.threshold]
77
+ if peaks.empty:
78
+ return []
79
+
80
+ result = []
81
+ window_size = dt.timedelta(minutes=15 * (2 * self.x_padding + 1))
82
+
83
+ for peak_time, peak_value in peaks.items():
84
+ peak_time = typing.cast(pd.Timestamp, peak_time)
85
+
86
+ if any(abs(peak_time - prev_peak[0]) < window_size for prev_peak in result):
87
+ continue
88
+
89
+ start_time = peak_time - dt.timedelta(minutes=15 * self.x_padding)
90
+ end_time = peak_time + dt.timedelta(minutes=15 * (self.x_padding + 1))
91
+ surrounding_data = self.data[start_time:end_time]
92
+
93
+ result.append(
94
+ [
95
+ peak_time,
96
+ peak_value,
97
+ surrounding_data,
98
+ ]
99
+ )
100
+ if len(result) == num_peaks:
101
+ break
102
+ return result
@@ -0,0 +1,30 @@
1
+ """Model for Capacity Analysis."""
2
+
3
+ import datetime as dt
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+ from openenergyid.models import TimeSeries
6
+
7
+
8
+ class CapacityInput(BaseModel):
9
+ """Model for capacity input"""
10
+
11
+ timezone: str = Field(alias="timeZone")
12
+ series: TimeSeries
13
+ threshold: float = Field(default=2.5, ge=0)
14
+
15
+
16
+ class PeakDetail(BaseModel):
17
+ """Model for peak detail"""
18
+
19
+ peak_time: dt.datetime = Field(alias="peakTime")
20
+ peak_value: float = Field(alias="peakValue")
21
+ surrounding_data: TimeSeries = Field(alias="surroundingData")
22
+ model_config = ConfigDict(populate_by_name=True)
23
+
24
+
25
+ class CapacityOutput(BaseModel):
26
+ """Model for capacity output"""
27
+
28
+ peaks: TimeSeries
29
+ peak_details: list[PeakDetail] = Field(alias="peakDetails")
30
+ model_config = ConfigDict(populate_by_name=True)
openenergyid/const.py ADDED
@@ -0,0 +1,18 @@
1
+ """Constants for the Open Energy ID package."""
2
+
3
+ from typing import Literal
4
+
5
+ # METRICS
6
+
7
+ ELECTRICITY_DELIVERED: Literal["electricity_delivered"] = "electricity_delivered"
8
+ ELECTRICITY_EXPORTED: Literal["electricity_exported"] = "electricity_exported"
9
+ ELECTRICITY_PRODUCED: Literal["electricity_produced"] = "electricity_produced"
10
+
11
+ PRICE_DAY_AHEAD: Literal["price_day_ahead"] = "price_day_ahead"
12
+ PRICE_IMBALANCE_UPWARD: Literal["price_imbalance_upward"] = "price_imbalance_upward"
13
+ PRICE_IMBALANCE_DOWNWARD: Literal["price_imbalance_downward"] = "price_imbalance_downward"
14
+ PRICE_ELECTRICITY_DELIVERED: Literal["price_electricity_delivered"] = "price_electricity_delivered"
15
+ PRICE_ELECTRICITY_EXPORTED: Literal["price_electricity_exported"] = "price_electricity_exported"
16
+
17
+ RLP: Literal["RLP"] = "RLP"
18
+ SPP: Literal["SPP"] = "SPP"
@@ -0,0 +1,20 @@
1
+ """Dynamic Tariff Analysis module."""
2
+
3
+ from .main import calculate_dyntar_columns, summarize_result
4
+ from .models import (
5
+ DynamicTariffAnalysisInput,
6
+ DynamicTariffAnalysisOutput,
7
+ DynamicTariffAnalysisOutputSummary,
8
+ OutputColumns,
9
+ RequiredColumns,
10
+ )
11
+
12
+ __all__ = [
13
+ "calculate_dyntar_columns",
14
+ "DynamicTariffAnalysisInput",
15
+ "DynamicTariffAnalysisOutput",
16
+ "DynamicTariffAnalysisOutputSummary",
17
+ "OutputColumns",
18
+ "RequiredColumns",
19
+ "summarize_result",
20
+ ]
@@ -0,0 +1,31 @@
1
+ """Constants for the dyntar analysis."""
2
+
3
+ from enum import Enum
4
+
5
+ ELECTRICITY_DELIVERED_SMR3 = "electricity_delivered_smr3"
6
+ ELECTRICITY_EXPORTED_SMR3 = "electricity_exported_smr3"
7
+ ELECTRICITY_DELIVERED_SMR2 = "electricity_delivered_smr2"
8
+ ELECTRICITY_EXPORTED_SMR2 = "electricity_exported_smr2"
9
+
10
+ COST_ELECTRICITY_DELIVERED_SMR2 = "cost_electricity_delivered_smr2"
11
+ COST_ELECTRICITY_EXPORTED_SMR2 = "cost_electricity_exported_smr2"
12
+ COST_ELECTRICITY_DELIVERED_SMR3 = "cost_electricity_delivered_smr3"
13
+ COST_ELECTRICITY_EXPORTED_SMR3 = "cost_electricity_exported_smr3"
14
+
15
+ RLP_WEIGHTED_PRICE_DELIVERED = "rlp_weighted_price_delivered"
16
+ SPP_WEIGHTED_PRICE_EXPORTED = "spp_weighted_price_exported"
17
+
18
+ HEATMAP_DELIVERED = "heatmap_delivered"
19
+ HEATMAP_EXPORTED = "heatmap_exported"
20
+ HEATMAP_TOTAL = "heatmap_total"
21
+
22
+ HEATMAP_DELIVERED_DESCRIPTION = "heatmap_delivered_description"
23
+ HEATMAP_EXPORTED_DESCRIPTION = "heatmap_exported_description"
24
+ HEATMAP_TOTAL_DESCRIPTION = "heatmap_total_description"
25
+
26
+
27
+ class Register(Enum):
28
+ """Register for dynamic tariff analysis."""
29
+
30
+ DELIVERY = "delivery"
31
+ EXPORT = "export"