from __future__ import annotations
import numpy as np
MATLAB_COLOR_ORDER = np.asarray(
[
[0.0660, 0.4430, 0.7450],
[0.8660, 0.3290, 0.0000],
[0.9290, 0.6940, 0.1250],
[0.4940, 0.1840, 0.5560],
[0.4660, 0.6740, 0.1880],
[0.3010, 0.7450, 0.9330],
[0.6350, 0.0780, 0.1840],
],
dtype=float,
)
[docs]
class ConfidenceInterval:
"""Confidence interval for a time series or Covariate.
Stores a pair of (lower, upper) bound traces over a shared time
axis, with plotting support for both line and shaded-patch styles.
Parameters
----------
time : array_like
Time vector.
bounds : array_like, shape (n, 2)
Lower and upper bounds at each time point.
*args
Positional metadata: ``(name, xlabelval, xunits, yunits,
dataLabels, plotProps)``. If a single short string is passed
it is interpreted as the colour (Matlab compatibility).
color : str or None
Line / patch colour. Default ``'b'``.
value : float, default 0.95
Confidence level (e.g. 0.95 for 95 %).
"""
[docs]
def __init__(self, time, bounds, *args, color: str | None = None, value: float = 0.95) -> None:
t = np.asarray(time, dtype=float).reshape(-1)
b = np.asarray(bounds, dtype=float)
if b.ndim != 2 or b.shape[1] != 2:
raise ValueError("bounds must have shape (n, 2)")
if b.shape[0] != t.shape[0]:
raise ValueError("bounds rows must match time length")
use_color_alias = bool(
len(args) == 1
and color is None
and isinstance(args[0], str)
and len(args[0]) <= 2
)
self.name = "" if use_color_alias or len(args) < 1 else str(args[0])
self.xlabelval = "time" if use_color_alias or len(args) < 2 else str(args[1])
self.xunits = "s" if use_color_alias or len(args) < 3 else str(args[2])
self.yunits = "" if use_color_alias or len(args) < 4 else str(args[3])
self.dataLabels = (
["lower", "upper"]
if use_color_alias or len(args) < 5 or args[4] is None
else self._normalize_text_sequence(args[4])
)
self.plotProps = (
[]
if use_color_alias or len(args) < 6 or args[5] is None
else self._normalize_plot_props(args[5])
)
self.time = t
self.bounds = b
self.data = self.bounds
self.dimension = int(self.bounds.shape[1])
self.minTime = float(self.time[0]) if self.time.size else float("nan")
self.maxTime = float(self.time[-1]) if self.time.size else float("nan")
self.sampleRate = (
float(1.0 / np.mean(np.diff(self.time)))
if self.time.size > 1 and np.all(np.diff(self.time) != 0)
else float("nan")
)
self.dataMask = np.ones(self.dimension, dtype=int)
self.color = str(args[0]) if use_color_alias else ("b" if color is None else str(color))
self.value = float(value)
if len(self.plotProps) == 1 and self.dimension > 1:
self.plotProps = self.plotProps * self.dimension
@staticmethod
def _normalize_text_sequence(values) -> list[str]:
if isinstance(values, (str, bytes)):
return [str(values)]
arr = np.asarray(values, dtype=object)
if arr.shape == ():
return [str(arr.item())]
return [str(item) for item in arr.reshape(-1)]
@staticmethod
def _normalize_plot_props(values) -> list:
if isinstance(values, (str, bytes)):
return [str(values)]
arr = np.asarray(values, dtype=object)
if arr.shape == ():
return [arr.item()]
return [item.item() if isinstance(item, np.generic) else item for item in arr.reshape(-1)]
@property
def lower(self) -> np.ndarray:
return self.bounds[:, 0]
@property
def upper(self) -> np.ndarray:
return self.bounds[:, 1]
[docs]
def setColor(self, color: str) -> None:
"""Set the plot colour."""
self.color = str(color)
[docs]
def setValue(self, value: float) -> None:
"""Set the confidence level (e.g. 0.95 for 95 %)."""
self.value = float(value)
[docs]
def dataToMatrix(self) -> np.ndarray:
"""Return the bounds as an (n, 2) numpy array."""
return np.asarray(self.bounds, dtype=float)
[docs]
def dataToStructure(self) -> dict:
"""Serialise to a plain dictionary (matches Matlab ``dataToStructure``)."""
return {
"time": self.time.tolist(),
"signals": {"values": self.bounds.tolist(), "dimensions": self.dimension},
"name": self.name,
"dimension": self.dimension,
"minTime": self.minTime,
"maxTime": self.maxTime,
"xlabelval": self.xlabelval,
"xunits": self.xunits,
"yunits": self.yunits,
"dataLabels": list(self.dataLabels),
"dataMask": self.dataMask.tolist(),
"sampleRate": self.sampleRate,
"plotProps": list(self.plotProps),
}
def _coerce_signal_values(self, other) -> np.ndarray:
if hasattr(other, "time") and hasattr(other, "data"):
other_time = np.asarray(other.time, dtype=float).reshape(-1)
if other_time.shape != self.time.shape or np.max(np.abs(other_time - self.time)) > 1e-9:
raise ValueError("ConfidenceInterval operations require matching time grids")
values = np.asarray(other.data, dtype=float)
if values.ndim == 2:
if values.shape[1] != 1:
raise ValueError("ConfidenceInterval arithmetic expects a scalar signal per operation")
values = values[:, 0]
return values.reshape(-1)
values = np.asarray(other, dtype=float)
if values.ndim == 0:
return np.full(self.time.shape, float(values), dtype=float)
return values.reshape(-1)
def __add__(self, other):
if isinstance(other, ConfidenceInterval):
if other.time.shape != self.time.shape or np.max(np.abs(other.time - self.time)) > 1e-9:
raise ValueError("ConfidenceInterval operations require matching time grids")
bounds = np.column_stack([self.lower + other.lower, self.upper + other.upper])
return ConfidenceInterval(self.time, bounds, self.color)
offset = self._coerce_signal_values(other)
return ConfidenceInterval(self.time, self.bounds + offset[:, None], self.color)
def __radd__(self, other):
return self + other
def __sub__(self, other):
if isinstance(other, ConfidenceInterval):
if other.time.shape != self.time.shape or np.max(np.abs(other.time - self.time)) > 1e-9:
raise ValueError("ConfidenceInterval operations require matching time grids")
bounds = np.column_stack([self.lower - other.upper, self.upper - other.lower])
return ConfidenceInterval(self.time, bounds, self.color)
offset = self._coerce_signal_values(other)
return ConfidenceInterval(self.time, self.bounds - offset[:, None], self.color)
def __rsub__(self, other):
offset = self._coerce_signal_values(other)
bounds = np.column_stack([offset - self.upper, offset - self.lower])
return ConfidenceInterval(self.time, bounds, self.color)
def __neg__(self):
return ConfidenceInterval(self.time, np.column_stack([-self.upper, -self.lower]), self.color)
# ------------------------------------------------------------------
# SignalObj-compatible interface methods
# In MATLAB, ConfidenceInterval < SignalObj, inheriting ~70 methods.
# We provide the most commonly used ones here for API parity.
# ------------------------------------------------------------------
[docs]
def getSigInTimeWindow(self, wMin: float, wMax: float, holdVals: int = 0):
"""Return a new ConfidenceInterval restricted to [wMin, wMax]."""
mask = (self.time >= wMin) & (self.time <= wMax)
return ConfidenceInterval(self.time[mask], self.bounds[mask], self.color, value=self.value)
[docs]
def windowedSignal(self, windowTimes):
"""Return a ConfidenceInterval restricted to the given time window."""
wt = np.asarray(windowTimes, dtype=float).reshape(-1)
return self.getSigInTimeWindow(float(wt[0]), float(wt[-1]))
[docs]
def shift(self, deltaT: float, updateLabels: bool = False):
"""Return a time-shifted copy."""
return ConfidenceInterval(self.time + deltaT, self.bounds.copy(), self.color, value=self.value)
[docs]
def resample(self, sample_rate: float):
"""Resample the CI bounds to a new sample rate via linear interpolation."""
new_time = np.arange(self.minTime, self.maxTime, 1.0 / sample_rate)
new_lower = np.interp(new_time, self.time, self.lower)
new_upper = np.interp(new_time, self.time, self.upper)
return ConfidenceInterval(new_time, np.column_stack([new_lower, new_upper]), self.color, value=self.value)
@property
def derivative(self):
"""Numerical derivative of both bounds."""
dt = np.diff(self.time)
dt[dt == 0] = 1.0
d_lower = np.concatenate([np.diff(self.lower) / dt, [0.0]])
d_upper = np.concatenate([np.diff(self.upper) / dt, [0.0]])
return ConfidenceInterval(self.time, np.column_stack([d_lower, d_upper]), self.color, value=self.value)
[docs]
def merge(self, other, holdVals: int = 0):
"""Merge with another ConfidenceInterval along the time axis."""
new_time = np.concatenate([self.time, other.time])
new_bounds = np.vstack([self.bounds, other.bounds])
order = np.argsort(new_time)
return ConfidenceInterval(new_time[order], new_bounds[order], self.color, value=self.value)
[docs]
def copySignal(self):
"""Return a deep copy."""
return ConfidenceInterval(self.time.copy(), self.bounds.copy(), self.color, value=self.value)
[docs]
def getSubSignal(self, identifier):
"""Return a single column (lower=0 or upper=1) as a 1-column CI."""
idx = int(identifier)
col = self.bounds[:, idx : idx + 1]
return ConfidenceInterval(self.time, np.column_stack([col, col]), self.color, value=self.value)
[docs]
def setMinTime(self, minTime=None, holdVals: int = 0):
"""Restrict to times >= minTime."""
if minTime is None:
return
mask = self.time >= minTime
self.time = self.time[mask]
self.bounds = self.bounds[mask]
self.minTime = float(self.time[0]) if self.time.size else float("nan")
[docs]
def setMaxTime(self, maxTime=None, holdVals: int = 0):
"""Restrict to times <= maxTime."""
if maxTime is None:
return
mask = self.time <= maxTime
self.time = self.time[mask]
self.bounds = self.bounds[mask]
self.maxTime = float(self.time[-1]) if self.time.size else float("nan")
[docs]
def toStructure(self) -> dict:
"""Alias for :meth:`dataToStructure`."""
return self.dataToStructure()
[docs]
def plot(self, color: str | None = None, alphaVal: float = 0.2, drawPatches: int = 0, ax=None):
"""Plot the confidence interval.
Parameters
----------
color : str or None
Override colour (default: ``self.color``).
alphaVal : float, default 0.2
Transparency for shaded patches.
drawPatches : int, default 0
If ``1``, draw a shaded ``fill_between`` region instead of
lines.
ax : Axes or None
Matplotlib axes. If ``None``, uses ``plt.gca()``.
Returns
-------
PolyCollection or list of Line2D
Plot handles.
"""
import matplotlib.pyplot as plt
axis = plt.gca() if ax is None else ax
plot_color = self.color if color is None else color
if drawPatches:
return axis.fill_between(self.time, self.lower, self.upper, color=plot_color, edgecolor="none", alpha=alphaVal)
lines = axis.plot(self.time, self.bounds)
for line in lines:
if plot_color is not None and not isinstance(plot_color, (str, bytes)):
line.set_color(plot_color)
if plot_color is None or isinstance(plot_color, (str, bytes)):
for index, line in enumerate(lines):
line.set_color(MATLAB_COLOR_ORDER[index % MATLAB_COLOR_ORDER.shape[0]])
return lines
[docs]
@staticmethod
def fromStructure(structure: dict) -> "ConfidenceInterval":
"""Reconstruct a ConfidenceInterval from a dictionary."""
signals = structure.get("signals", {})
values = signals.get("values", structure.get("data"))
ci = ConfidenceInterval(
structure["time"],
values,
structure.get("name", ""),
structure.get("xlabelval", "time"),
structure.get("xunits", "s"),
structure.get("yunits", ""),
structure.get("dataLabels"),
structure.get("plotProps"),
)
if "dataMask" in structure:
ci.dataMask = np.asarray(structure["dataMask"], dtype=int).reshape(-1)
if "sampleRate" in structure:
ci.sampleRate = float(structure["sampleRate"])
if "minTime" in structure:
ci.minTime = float(structure["minTime"])
if "maxTime" in structure:
ci.maxTime = float(structure["maxTime"])
return ci