"""Explicit sample clocks, typed resampling and file evidence for reproducible runs."""
import datetime
import hashlib
import math
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
from citylearn.utilities import parse_bool
DEFINITION_VERSION = "temporal-evidence-v1"
FILE_TYPES = {
".csv",
".parquet",
".pq",
".json",
".pkl",
".pickle",
".pt",
".pth",
".joblib",
".npy",
".npz",
".epw",
".txt",
}
[docs]
def sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
[docs]
def signature(path):
s = Path(path).stat()
return (s.st_size, s.st_mtime_ns, s.st_ino)
[docs]
def runtime_versions():
from importlib.metadata import version, PackageNotFoundError
import platform
result = dict(
python=platform.python_version(),
implementation=platform.python_implementation(),
system=platform.system(),
machine=platform.machine(),
)
for name in ("numpy", "pandas", "pyarrow", "gymnasium", "torch", "scikit-learn"):
try:
result[name] = version(name)
except PackageNotFoundError:
result[name] = None
return result
[docs]
class TimeAxis:
def __init__(self, schema):
self.schema = schema
self.timezone = schema.get("timezone")
if self.timezone:
ZoneInfo(self.timezone)
root = Path(schema.get("root_directory") or ".")
filename = (schema.get("time_axis") or {}).get("calendar_file")
if filename is None and str(schema.get("dataset_contract_version", "")).startswith("portugal-native") and (root / "shared/calendar.parquet").is_file():
filename = "shared/calendar.parquet"
self.filename = filename
self.utc = None
self.seconds = None
if filename:
path = Path(filename)
path = path if path.is_absolute() else root / path
frame = (
pd.read_parquet(path)
if path.suffix in {".parquet", ".pq"}
else pd.read_csv(path)
)
if not {"time_step", "timestamp_utc"} <= set(frame):
raise ValueError(
"Canonical calendar requires time_step and timestamp_utc"
)
if not np.array_equal(frame.time_step.to_numpy(), np.arange(len(frame))):
raise ValueError("Calendar indexes must be contiguous from zero")
self.utc = pd.DatetimeIndex(
pd.to_datetime(frame.timestamp_utc, utc=True, errors="raise")
).astype("datetime64[ns, UTC]")
if self.utc.hasnans:
raise ValueError("Calendar contains missing timestamps")
differences = np.diff(self.utc.asi8) / 1e9
if (
len(differences) == 0
or not np.isfinite(differences).all()
or np.any(differences <= 0)
or not np.all(differences == differences[0])
):
raise ValueError(
"Calendar UTC timestamps must be unique, increasing and regular"
)
self.seconds = float(differences[0])
if "timestamp_local" in frame and not np.array_equal(
pd.to_datetime(frame.timestamp_local, utc=True)
.astype("datetime64[ns, UTC]").astype("int64"),
self.utc.asi8,
):
raise ValueError("Local and UTC calendar instants disagree")
declared = schema.get("dataset_seconds_per_time_step")
if declared is not None:
if not np.isfinite(declared) or declared <= 0:
raise ValueError("Dataset timestep must be positive and finite")
if self.seconds is not None and float(declared) != self.seconds:
raise ValueError("Declared dataset timestep disagrees with calendar")
self.seconds = float(declared)
[docs]
def at(self, index):
if self.utc is None:
return None
if 0 <= index < len(self.utc):
value = self.utc[index]
elif index == len(self.utc) and parse_bool(
self.schema.get("terminal_observation_padding", False),
default=False,
path="terminal_observation_padding",
):
value = self.utc[-1] + pd.Timedelta(seconds=self.seconds)
else:
raise ValueError("Sample index is outside the canonical calendar")
return value.tz_convert(self.timezone or "UTC").to_pydatetime()
[docs]
def resample_series(values, source_seconds, target_seconds, *, kind, weights=None):
values = np.asarray(values, dtype=float)
if values.ndim != 1 or not np.isfinite(values).all():
raise ValueError("Finite one-dimensional values are required")
s, t = float(source_seconds), float(target_seconds)
if not np.isfinite(s + t) or min(s, t) <= 0:
raise ValueError("Intervals must be positive and finite")
if kind not in {"energy", "power", "price", "intensity"}:
raise ValueError("Categorical states/events require exact boundary mapping")
duration = len(values) * s
n = int(round(duration / t))
if not math.isclose(n * t, duration, rel_tol=1e-12, abs_tol=1e-9):
raise ValueError("Target interval does not cover the complete source duration")
if weights is not None:
weights = np.asarray(weights, dtype=float)
if (
kind not in {"price", "intensity"}
or weights.shape != values.shape
or not np.isfinite(weights).all()
or (weights < 0).any()
):
raise ValueError("Nonnegative energy weights apply only to price/intensity")
result = np.zeros(n)
denominator = np.zeros(n)
i = j = 0
while i < len(values) and j < n:
overlap = min((i + 1) * s, (j + 1) * t) - max(i * s, j * t)
weight = overlap if weights is None else overlap / s * weights[i]
result[j] += values[i] * (overlap / s if kind == "energy" else weight)
denominator[j] += weight
if (i + 1) * s <= (j + 1) * t:
i += 1
else:
j += 1
if kind != "energy":
result = np.divide(
result, denominator, out=np.full(n, np.nan), where=denominator > 0
)
return result
[docs]
def map_interval(start, end, source_seconds, target_seconds, *, end_inclusive=False):
if (
not np.isfinite(source_seconds + target_seconds)
or min(source_seconds, target_seconds) <= 0
):
raise ValueError("Invalid interval duration")
if int(start) != start or int(end) != end or start < 0 or end < start:
raise ValueError("Invalid event interval")
def boundary(index):
v = index * source_seconds / target_seconds
if not math.isclose(v, round(v), rel_tol=1e-12, abs_tol=1e-9):
raise ValueError("Event boundary cannot be represented exactly")
return int(round(v))
return boundary(start), boundary(end + int(end_inclusive)) - int(end_inclusive)
[docs]
def rescale_step_loss(loss, source_seconds, target_seconds):
if (
not 0 <= loss <= 1
or not np.isfinite(source_seconds + target_seconds)
or min(source_seconds, target_seconds) <= 0
):
raise ValueError("Invalid loss or interval")
return 1 - (1 - loss) ** (target_seconds / source_seconds)