///|
/// A sample retains its zero-based source record and within-record sample index.
pub struct TimedSample {
record : Int
sample : Int
time : Double
digital : Int
physical : Double
} derive(Debug, ToJson)
///|
/// An interval in seconds relative to the original header origin, not a new file.
/// Gaps are intersections with known inter-record gaps, not extrapolated padding.
pub struct SignalWindow {
signal : Int
label : String
unit : String
start : Double
end : Double
samples : Array[TimedSample]
gaps : Array[Gap]
} derive(Debug, ToJson)
///|
pub extend TimedSample with @debug.Debug::{to_repr}
///|
pub extend SignalWindow with @debug.Debug::{to_repr}
///|
pub extend TimedSample with ToJson::{to_json}
///|
pub extend SignalWindow with ToJson::{to_json}
///|
fn check_window(
start : Double,
end : Double,
max_samples : Int,
) -> Unit raise EdfError {
if !finite(start) || !finite(end) || start > end {
raise Invalid("window requires finite start <= end seconds")
}
if max_samples < 1 || max_samples > 1000000 {
raise Invalid("window sample limit must be between 1 and 1000000")
}
}
///|
// Match sample_time's floating-point expression exactly. Searching timestamps,
// rather than rounding (bound-onset)*rate, keeps exact endpoint membership and
// supports zero-duration event records without dividing by their duration.
fn time_lower_bound(
onset : Double,
duration : Double,
per : Int,
bound : Double,
) -> Int {
let mut lo = 0
let mut hi = per
while lo < hi {
let mid = lo + (hi - lo) / 2
if onset + duration * mid.to_double() / per.to_double() < bound {
lo = mid + 1
} else {
hi = mid
}
}
lo
}
///|
// Preflight selection and its budget before materializing values. Tuples are
// (record, first sample, exclusive end); limits apply to samples, not records.
fn Recording::window_spans(
self : Recording,
signal : Int,
start : Double,
end : Double,
budget : Int,
) -> (Array[(Int, Int, Int)], Int) raise EdfError {
if signal < 0 ||
signal >= self.header.signals.length() ||
self.header.signals[signal].is_annotation() {
raise Invalid("ordinary signal index required")
}
let spans = []
let mut count = 0
if start == end {
return (spans, count)
}
let per = self.header.signals[signal].samples_per_record
for r = 0; r < self.records; r = r + 1 {
let onset = self.starts[r]
if !finite(
onset + self.header.duration * (per - 1).to_double() / per.to_double(),
) {
raise Limit("window sample timestamp overflow")
}
let first = time_lower_bound(onset, self.header.duration, per, start)
let last = time_lower_bound(onset, self.header.duration, per, end)
if last - first > budget - count {
raise Limit("window exceeds requested sample limit")
}
if first < last {
spans.push((r, first, last))
count += last - first
}
// Do not stop early: decode tolerates sub-100ns backwards timekeeping noise.
}
(spans, count)
}
///|
/// Extract samples whose timestamps are in [start, end), in source-file order.
/// Bounds are finite seconds relative to the original header; negative bounds
/// are allowed. No interpolation, resampling, rebasing or gap filling occurs.
/// Equal bounds return an empty window. Exceeding max_samples raises Limit;
/// nothing is silently truncated. Physical calibration failures are propagated.
pub fn Recording::time_window(
self : Recording,
signal : Int,
start : Double,
end : Double,
max_samples? : Int = 65536,
) -> SignalWindow raise EdfError {
check_window(start, end, max_samples)
let (spans, _) = self.window_spans(signal, start, end, max_samples)
let s = self.header.signals[signal]
let samples = []
for span in spans {
let (r, first, last) = span
for j = first; j < last; j = j + 1 {
let digital = self.digital(signal, r, j)
samples.push({
record: r,
sample: j,
time: self.sample_time(signal, r, j),
digital,
physical: s.to_physical(digital),
})
}
}
let gaps = []
if start < end {
for r = 1; r < self.records; r = r + 1 {
let previous_end = self.starts[r - 1] + self.header.duration
if self.starts[r] - previous_end > 1.0e-7 {
let lo = previous_end.max(start)
let hi = self.starts[r].min(end)
if lo < hi {
gaps.push({ after_record: r - 1, start: lo, end: hi, })
}
}
}
}
{ signal, label: s.label, unit: s.unit, start, end, samples, gaps, }
}
///|
/// Long-form time-window CSV, grouped by requested channel, then source order.
/// Original indices/times are retained; there are no rows for missing time.
/// At least one distinct ordinary channel is required. The row budget is shared
/// across channels and checked before any values or CSV strings are generated.
pub fn Recording::window_csv(
self : Recording,
indices : Array[Int],
start : Double,
end : Double,
physical? : Bool = true,
max_samples? : Int = 65536,
) -> String raise EdfError {
check_window(start, end, max_samples)
if indices.is_empty() {
raise Invalid("window CSV requires at least one ordinary signal")
}
let seen : Map[Int, Bool] = Map([])
let selections = []
let mut rows = 0
for s in indices {
if seen.contains(s) {
raise Invalid("window CSV signal indices must be distinct")
}
seen[s] = true
let (spans, count) = self.window_spans(s, start, end, max_samples - rows)
rows += count
selections.push((s, spans))
}
let lines = ["signal,label,record,sample,time_seconds,value,unit"]
for selection in selections {
let (s, spans) = selection
let signal = self.header.signals[s]
let label = csv_quote(signal.label)
let unit = csv_quote(if physical { signal.unit } else { "digital" })
for span in spans {
let (r, first, last) = span
for j = first; j < last; j = j + 1 {
let value = if physical {
self.physical(s, r, j).to_string()
} else {
self.digital(s, r, j).to_string()
}
lines.push(
"\{s},\{label},\{r},\{j},\{self.sample_time(s,r,j)},\{value},\{unit}",
)
}
}
}
lines.join("\n") + "\n"
}