Paya / code /paya /inputs.py
yeabwang's picture
Paya model bundle and revised model card
534cc76
Raw History Blame Contribute Delete
5.92 kB
"""Validate native signal samples, required metadata and separate text inputs."""
from collections.abc import Mapping
from dataclasses import dataclass
import math
from numbers import Real
import numpy as np
from .vendor import signal
TYPES = ("eeg", "ieeg", "ecg", "ppg")
SIGNAL_CONDITIONS = ("raw", "processed")
class UnsupportedSignalError(ValueError):
"""The supplied measurements cannot satisfy a supported preparation."""
@dataclass(frozen=True)
class Channel:
samples: np.ndarray
sensor_type: str
rate: float
start: float
metadata: dict
def positive_number(value, name):
if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real):
raise ValueError(f"{name} must be a real number, not a string or boolean")
value = float(value)
if not math.isfinite(value) or value <= 0:
raise ValueError(f"{name} must be finite and positive")
return value
def validate_text(question, criteria):
if not isinstance(question, str) or not question.strip():
raise ValueError("question must be a nonempty string")
if not isinstance(criteria, list) or not 2 <= len(criteria) <= 64:
raise ValueError("criteria must be an ordered list of 2–64 strings")
if any(not isinstance(c, str) or not c.strip() for c in criteria):
raise ValueError("criteria entries must be nonempty strings")
if len({c.strip() for c in criteria}) != len(criteria):
raise ValueError("criteria entries must be distinct")
def validate_state(state, max_channels=signal.MAX_CHANNELS, max_samples=signal.MAX_SAMPLES):
if not isinstance(state, Mapping) or not isinstance(state.get("signals"), list):
raise ValueError("state must contain a signals list")
if not 1 <= len(state["signals"]) <= max_channels:
raise ValueError(f"Supply 1–{max_channels} physical channels")
channels = []
for entry in state["signals"]:
if not isinstance(entry, Mapping) or not isinstance(entry.get("metadata"), Mapping):
raise ValueError("Each signal needs samples and a metadata object")
meta = dict(entry["metadata"])
kind = meta.get("sensor_type")
if not isinstance(kind, str) or kind.strip().lower() not in TYPES:
raise ValueError(f"metadata.sensor_type must be one of {TYPES}")
kind = kind.strip().lower()
rate = positive_number(meta.get("sampling_rate_hz"), "metadata.sampling_rate_hz")
duration = positive_number(meta.get("duration_s"), "metadata.duration_s")
condition = meta.get("signal_condition", "raw")
if not isinstance(condition, str) or condition not in SIGNAL_CONDITIONS:
raise ValueError("metadata.signal_condition must be raw or processed")
# Units and lead labels are outside the input contract; values retain their supplied scale.
meta.pop("unit", None)
meta.pop("lead", None)
if "powerline_hz" in meta:
powerline = meta["powerline_hz"]
if isinstance(powerline, (bool, np.bool_)) or not isinstance(powerline, Real) or powerline not in (0, 50, 60):
raise ValueError("metadata.powerline_hz must be numeric 0, 50, or 60")
raw = np.asarray(entry.get("samples"))
if raw.ndim != 1 or raw.dtype.kind not in "iuf" or not signal.MIN_SAMPLES <= raw.size <= max_samples:
raise ValueError(f"samples must contain {signal.MIN_SAMPLES}–{max_samples} real numeric measurements")
values = raw.astype(np.float64)
if not np.isfinite(values).all():
raise ValueError("Samples must be finite; missing intervals cannot be filled silently")
with np.errstate(over="ignore", invalid="ignore"):
values = values.astype(np.float32)
if not np.isfinite(values).all():
raise ValueError("Samples exceed the float32 waveform representation")
start = meta.get("start_time_s", 0.0)
if isinstance(start, (bool, np.bool_)) or not isinstance(start, Real) or not math.isfinite(start):
raise ValueError("metadata.start_time_s must be finite")
measured_duration = len(values)/rate
if not math.isfinite(measured_duration) or not math.isclose(
duration, measured_duration, rel_tol=1e-12, abs_tol=1e-9):
raise ValueError("metadata.duration_s disagrees with samples and sampling rate")
if "timestamps_s" in meta:
ts = np.asarray(meta["timestamps_s"], dtype=np.float64)
if ts.shape != values.shape or not np.isfinite(ts).all():
raise ValueError("Timestamps must be finite and match samples")
if not np.allclose(np.diff(ts), 1/rate, rtol=0.01, atol=1e-9):
raise ValueError("Timestamps disagree with the declared uniform sampling rate")
if not math.isclose(ts[0], start, rel_tol=0, abs_tol=max(1e-9, 0.01/rate)):
raise ValueError("Timestamps disagree with start_time_s")
for field in ("placement", "reference"):
if field in meta and (not isinstance(meta[field], str) or not meta[field].strip()):
raise ValueError(f"metadata.{field} must be a nonempty string when supplied")
meta.update(sensor_type=kind, sampling_rate_hz=rate, duration_s=duration, signal_condition=condition)
channels.append(Channel(values, kind, rate, float(start), meta))
first = channels[0]
for c in channels[1:]:
if c.rate != first.rate or len(c.samples) != len(first.samples):
raise ValueError("All channels must have the same sampling rate and sample count for one encoder input")
if abs(c.start-first.start) > max(1e-9, 0.01/first.rate):
raise ValueError("Channels must have the same start_time_s")
return channels