EOS/src/akkudoktoreos/measurement/measurement.py

264 lines
10 KiB
Python
Raw Normal View History

Fix2 config and predictions revamp. (#281) measurement: - Add new measurement class to hold real world measurements. - Handles load meter readings, grid import and export meter readings. - Aggregates load meter readings aka. measurements to total load. - Can import measurements from files, pandas datetime series, pandas datetime dataframes, simple daetime arrays and programmatically. - Maybe expanded to other measurement values. - Should be used for load prediction adaptions by real world measurements. core/coreabc: - Add mixin class to access measurements core/pydantic: - Add pydantic models for pandas datetime series and dataframes. - Add pydantic models for simple datetime array core/dataabc: - Provide DataImport mixin class for generic import handling. Imports from JSON string and files. Imports from pandas datetime dataframes and simple datetime arrays. Signature of import method changed to allow import datetimes to be given programmatically and by data content. - Use pydantic models for datetime series, dataframes, arrays - Validate generic imports by pydantic models - Provide new attributes min_datetime and max_datetime for DataSequence. - Add parameter dropna to drop NAN/ None values when creating lists, pandas series or numpy array from DataSequence. config/config: - Add common settings for the measurement module. predictions/elecpriceakkudoktor: - Use mean values of last 7 days to fill prediction values not provided by akkudoktor.net (only provides 24 values). prediction/loadabc: - Extend the generic prediction keys by 'load_total_adjusted' for load predictions that adjust the predicted total load by measured load values. prediction/loadakkudoktor: - Extend the Akkudoktor load prediction by load adjustment using measured load values. prediction/load_aggregator: - Module removed. Load aggregation is now handled by the measurement module. prediction/load_corrector: - Module removed. Load correction (aka. adjustment of load prediction by measured load energy) is handled by the LoadAkkudoktor prediction and the generic 'load_mean_adjusted' prediction key. prediction/load_forecast: - Module removed. Functionality now completely handled by the LoadAkkudoktor prediction. utils/cacheutil: - Use pydantic. - Fix potential bug in ttl (time to live) duration handling. utils/datetimeutil: - Added missing handling of pendulum.DateTime and pendulum.Duration instances as input. Handled before as datetime.datetime and datetime.timedelta. utils/visualize: - Move main to generate_example_report() for better testing support. server/server: - Added new configuration option server_fastapi_startup_server_fasthtml to make startup of FastHTML server by FastAPI server conditional. server/fastapi_server: - Add APIs for measurements - Improve APIs to provide or take pandas datetime series and datetime dataframes controlled by pydantic model. - Improve APIs to provide or take simple datetime data arrays controlled by pydantic model. - Move fastAPI server API to v1 for new APIs. - Update pre v1 endpoints to use new prediction and measurement capabilities. - Only start FastHTML server if 'server_fastapi_startup_server_fasthtml' config option is set. tests: - Adapt import tests to changed import method signature - Adapt server test to use the v1 API - Extend the dataabc test to test for array generation from data with several data interval scenarios. - Extend the datetimeutil test to also test for correct handling of to_datetime() providing now(). - Adapt LoadAkkudoktor test for new adjustment calculation. - Adapt visualization test to use example report function instead of visualize.py run as process. - Removed test_load_aggregator. Functionality is now tested in test_measurement. - Added tests for measurement module docs: - Remove sphinxcontrib-openapi as it prevents build of documentation. "site-packages/sphinxcontrib/openapi/openapi31.py", line 305, in _get_type_from_schema for t in schema["anyOf"]: KeyError: 'anyOf'" Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2024-12-29 18:42:49 +01:00
"""Measurement module to provide and store measurements.
This module provides a `Measurement` class to manage and update a sequence of
data records for measurements.
The measurements can be added programmatically or imported from a file or JSON string.
"""
from typing import Any, ClassVar, List, Optional
import numpy as np
from numpydantic import NDArray, Shape
from pendulum import DateTime, Duration
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.dataabc import DataImportMixin, DataRecord, DataSequence
from akkudoktoreos.utils.datetimeutil import to_duration
from akkudoktoreos.utils.logutil import get_logger
logger = get_logger(__name__)
class MeasurementCommonSettings(SettingsBaseModel):
measurement_load0_name: Optional[str] = Field(
default=None, description="Name of the load0 source (e.g. 'Household', 'Heat Pump')"
)
measurement_load1_name: Optional[str] = Field(
default=None, description="Name of the load1 source (e.g. 'Household', 'Heat Pump')"
)
measurement_load2_name: Optional[str] = Field(
default=None, description="Name of the load2 source (e.g. 'Household', 'Heat Pump')"
)
measurement_load3_name: Optional[str] = Field(
default=None, description="Name of the load3 source (e.g. 'Household', 'Heat Pump')"
)
measurement_load4_name: Optional[str] = Field(
default=None, description="Name of the load4 source (e.g. 'Household', 'Heat Pump')"
)
class MeasurementDataRecord(DataRecord):
"""Represents a measurement data record containing various measurements at a specific datetime.
Attributes:
date_time (Optional[DateTime]): The datetime of the record.
"""
# Single loads, to be aggregated to total load
measurement_load0_mr: Optional[float] = Field(
default=None, ge=0, description="Load0 meter reading [kWh]"
)
measurement_load1_mr: Optional[float] = Field(
default=None, ge=0, description="Load1 meter reading [kWh]"
)
measurement_load2_mr: Optional[float] = Field(
default=None, ge=0, description="Load2 meter reading [kWh]"
)
measurement_load3_mr: Optional[float] = Field(
default=None, ge=0, description="Load3 meter reading [kWh]"
)
measurement_load4_mr: Optional[float] = Field(
default=None, ge=0, description="Load4 meter reading [kWh]"
)
measurement_max_loads: ClassVar[int] = 5 # Maximum number of loads that can be set
measurement_grid_export_mr: Optional[float] = Field(
default=None, ge=0, description="Export to grid meter reading [kWh]"
)
measurement_grid_import_mr: Optional[float] = Field(
default=None, ge=0, description="Import from grid meter reading [kWh]"
)
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def measurement_loads(self) -> List[str]:
"""Compute a list of active loads."""
active_loads = []
# Loop through measurement_loadx
for i in range(self.measurement_max_loads):
load_attr = f"measurement_load{i}_mr"
# Check if either attribute is set and add to active loads
if getattr(self, load_attr, None):
active_loads.append(load_attr)
return active_loads
class Measurement(SingletonMixin, DataImportMixin, DataSequence):
"""Singleton class that holds measurement data records.
Measurements can be provided programmatically or read from JSON string or file.
"""
records: List[MeasurementDataRecord] = Field(
default_factory=list, description="List of measurement data records"
)
topics: ClassVar[List[str]] = [
"measurement_load",
]
def _interval_count(
self, start_datetime: DateTime, end_datetime: DateTime, interval: Duration
) -> int:
"""Calculate number of intervals between two datetimes.
Args:
start_datetime: Starting datetime
end_datetime: Ending datetime
interval: Time duration for each interval
Returns:
Number of intervals as integer
Raises:
ValueError: If end_datetime is before start_datetime
ValueError: If interval is zero or negative
"""
if end_datetime < start_datetime:
raise ValueError("end_datetime must be after start_datetime")
if interval.total_seconds() <= 0:
raise ValueError("interval must be positive")
# Calculate difference in seconds
diff_seconds = end_datetime.diff(start_datetime).total_seconds()
interval_seconds = interval.total_seconds()
# Return ceiling of division to include partial intervals
return int(np.ceil(diff_seconds / interval_seconds))
def name_to_key(self, name: str, topic: str) -> Optional[str]:
"""Provides measurement key for given name and topic."""
topic = topic.lower()
if topic not in self.topics:
return None
topic_keys = [key for key in self.config.config_keys if key.startswith(topic)]
key = None
if topic == "measurement_load":
for config_key in topic_keys:
if config_key.endswith("_name") and getattr(self.config, config_key) == name:
key = topic + config_key[len(topic) : len(topic) + 1] + "_mr"
break
if key is not None and key not in self.record_keys:
# Should never happen
error_msg = f"Key '{key}' not available."
logger.error(error_msg)
raise KeyError(error_msg)
return key
def _energy_from_meter_readings(
self,
key: str,
start_datetime: DateTime,
end_datetime: DateTime,
interval: Duration,
) -> NDArray[Shape["*"], Any]:
"""Calculate an energy values array indexed by fixed time intervals from energy metering data within an optional date range.
Args:
key: Key for energy meter readings.
start_datetime (datetime): The start date for filtering the energy data (inclusive).
end_datetime (datetime): The end date for filtering the energy data (exclusive).
interval (duration): The fixed time interval.
Returns:
np.ndarray: A NumPy Array of the energy [kWh] per interval values calculated from
the meter readings.
"""
# Add one interval to end_datetime to assure we have a energy value interval for all
# datetimes from start_datetime (inclusive) to end_datetime (exclusive)
end_datetime += interval
size = self._interval_count(start_datetime, end_datetime, interval)
energy_mr_array = self.key_to_array(
key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
)
if energy_mr_array.size != size:
logging_msg = (
f"'{key}' meter reading array size: {energy_mr_array.size}"
f" does not fit to expected size: {size}, {energy_mr_array}"
)
if energy_mr_array.size != 0:
logger.error(logging_msg)
raise ValueError(logging_msg)
logger.debug(logging_msg)
energy_array = np.zeros(size - 1)
elif np.any(energy_mr_array == None):
# 'key_to_array()' creates None values array if no data records are available.
# Array contains None value -> ignore
debug_msg = f"'{key}' meter reading None: {energy_mr_array}"
logger.debug(debug_msg)
energy_array = np.zeros(size - 1)
else:
# Calculate load per interval
debug_msg = f"'{key}' meter reading: {energy_mr_array}"
logger.debug(debug_msg)
energy_array = np.diff(energy_mr_array)
debug_msg = f"'{key}' energy calculation: {energy_array}"
logger.debug(debug_msg)
return energy_array
def load_total(
self,
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
interval: Optional[Duration] = None,
) -> NDArray[Shape["*"], Any]:
"""Calculate a total load energy values array indexed by fixed time intervals from load metering data within an optional date range.
Args:
start_datetime (datetime, optional): The start date for filtering the load data (inclusive).
end_datetime (datetime, optional): The end date for filtering the load data (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
Returns:
np.ndarray: A NumPy Array of the total load energy [kWh] per interval values calculated from
the load meter readings.
"""
if len(self) < 1:
# No data available
if start_datetime is None or end_datetime is None:
size = 0
else:
size = self._interval_count(start_datetime, end_datetime, interval)
return np.zeros(size)
if interval is None:
interval = to_duration("1 hour")
if start_datetime is None:
start_datetime = self[0].date_time
if end_datetime is None:
end_datetime = self[-1].date_time
size = self._interval_count(start_datetime, end_datetime, interval)
load_total_array = np.zeros(size)
# Loop through measurement_load<x>_mr
for i in range(self.record_class().measurement_max_loads):
key = f"measurement_load{i}_mr"
# Calculate load per interval
load_array = self._energy_from_meter_readings(
key=key, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
)
# Add calculated load to total load
load_total_array += load_array
debug_msg = f"Total load '{key}' calculation: {load_total_array}"
logger.debug(debug_msg)
return load_total_array
def get_measurement() -> Measurement:
"""Gets the EOS measurement data."""
return Measurement()