use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operator {
LessThan,
GreaterThan,
}
impl fmt::Display for Operator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Operator::LessThan => write!(f, "<"),
Operator::GreaterThan => write!(f, ">"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimingError {
OutOfRange {
message: String,
},
Ambiguous {
message: String,
},
Unsupported {
message: String,
},
InvalidDuration {
field: &'static str,
input: String,
reason: String,
},
}
impl fmt::Display for TimingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TimingError::OutOfRange { message }
| TimingError::Ambiguous { message }
| TimingError::Unsupported { message } => write!(f, "{message}"),
TimingError::InvalidDuration {
field,
input,
reason,
} => write!(f, "invalid duration '{input}' in {field}: {reason}"),
}
}
}
pub fn flap_crossing_secs(
op: Operator,
threshold: f64,
up_duration_secs: f64,
_down_duration_secs: f64,
up_value: f64,
down_value: f64,
) -> Result<f64, TimingError> {
let (lo, hi) = if up_value >= down_value {
(down_value, up_value)
} else {
(up_value, down_value)
};
match op {
Operator::LessThan => {
if threshold <= lo {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or below the down_value {down_value}; \
the flap signal never goes below it"
),
});
}
if threshold > hi {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is above up_value {up_value}; \
the flap signal is always below it (satisfied at t=0)"
),
});
}
if down_value < threshold {
Ok(up_duration_secs)
} else {
Err(TimingError::OutOfRange {
message: format!(
"down_value {down_value} is not less than threshold {threshold}; \
the flap signal never drops below {threshold}"
),
})
}
}
Operator::GreaterThan => {
if threshold >= hi {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or above up_value {up_value}; \
the flap signal never exceeds it"
),
});
}
if threshold < lo {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is below down_value {down_value}; \
the flap signal is always above it (satisfied at t=0)"
),
});
}
if up_value > threshold {
return Err(TimingError::Ambiguous {
message: format!(
"\"flap_signal > {threshold}\" is satisfied at t=0 \
(starts at up_value {up_value}). \
Use \"<\" to detect the down event"
),
});
}
Err(TimingError::OutOfRange {
message: format!(
"up_value {up_value} equals threshold {threshold}; \
the signal never strictly exceeds it"
),
})
}
}
}
pub fn sawtooth_crossing_secs(
op: Operator,
threshold: f64,
baseline: f64,
ceiling: f64,
period_secs: f64,
) -> Result<f64, TimingError> {
let range = ceiling - baseline;
if range.abs() < f64::EPSILON {
return Err(TimingError::OutOfRange {
message: format!(
"baseline ({baseline}) equals ceiling ({ceiling}); \
the signal is constant and cannot cross any threshold"
),
});
}
let (lo, hi) = if baseline <= ceiling {
(baseline, ceiling)
} else {
(ceiling, baseline)
};
match op {
Operator::GreaterThan => {
if threshold >= hi {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or above ceiling {ceiling}; \
the signal never exceeds it"
),
});
}
if threshold < lo {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is below baseline {baseline}; \
the signal starts above it (satisfied at t=0)"
),
});
}
let fraction = (threshold - baseline) / range;
Ok(fraction * period_secs)
}
Operator::LessThan => {
if threshold <= lo {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or below baseline {baseline}; \
the signal never drops below it"
),
});
}
if threshold > hi {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is above ceiling {ceiling}; \
the signal is always below it (satisfied at t=0)"
),
});
}
if baseline < threshold {
return Err(TimingError::Ambiguous {
message: format!(
"signal starts at baseline {baseline} which is already \
below threshold {threshold} (satisfied at t=0)"
),
});
}
Err(TimingError::OutOfRange {
message: format!(
"the sawtooth ramps from {baseline} toward {ceiling}; \
it does not cross below {threshold} during the ramp"
),
})
}
}
}
pub fn spike_crossing_secs(
op: Operator,
threshold: f64,
baseline: f64,
spike_height: f64,
spike_duration_secs: f64,
) -> Result<f64, TimingError> {
let peak = baseline + spike_height;
let lo = baseline.min(peak);
let hi = baseline.max(peak);
match op {
Operator::GreaterThan => {
if threshold >= hi {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or above peak value {peak}; \
the signal never exceeds it"
),
});
}
if threshold < lo {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is below baseline {baseline}; \
the signal is always above it (satisfied at t=0)"
),
});
}
if peak > threshold {
return Err(TimingError::Ambiguous {
message: format!(
"\"> {threshold}\" is satisfied at t=0 (spike starts immediately \
at peak {peak}). Use \"<\" to detect when the spike ends"
),
});
}
Err(TimingError::OutOfRange {
message: format!("peak {peak} does not exceed threshold {threshold}"),
})
}
Operator::LessThan => {
if threshold <= lo {
return Err(TimingError::OutOfRange {
message: format!(
"threshold {threshold} is at or below baseline {baseline}; \
the signal never drops below it"
),
});
}
if threshold > hi {
return Err(TimingError::Ambiguous {
message: format!(
"threshold {threshold} is above peak {peak}; \
the signal is always below it (satisfied at t=0)"
),
});
}
if baseline < threshold {
Ok(spike_duration_secs)
} else {
Err(TimingError::OutOfRange {
message: format!(
"baseline {baseline} is not below threshold {threshold}; \
the signal does not cross below it when the spike ends"
),
})
}
}
}
}
pub fn steady_crossing_secs() -> Result<f64, TimingError> {
Err(TimingError::Unsupported {
message: "cannot compute crossing for \"steady\" behavior \
-- sine waves cross any threshold twice per period, \
making the result ambiguous. Use explicit phase_offset instead"
.to_string(),
})
}
pub fn step_crossing_secs(
op: Operator,
threshold: f64,
start: f64,
step_size: f64,
max: Option<f64>,
rate: f64,
) -> Result<f64, TimingError> {
if !rate.is_finite() || rate <= 0.0 {
return Err(TimingError::OutOfRange {
message: format!("step rate {rate} must be positive and finite"),
});
}
match op {
Operator::LessThan => Err(TimingError::Unsupported {
message: "step generator does not support `< threshold`; \
the value is monotonically non-decreasing"
.to_string(),
}),
Operator::GreaterThan => {
if step_size == 0.0 {
return Err(TimingError::OutOfRange {
message: "step_size is 0; the signal never advances".to_string(),
});
}
if step_size < 0.0 {
return Err(TimingError::OutOfRange {
message: format!(
"step_size {step_size} is negative; \
the signal moves away from `> threshold`"
),
});
}
if start > threshold {
return Err(TimingError::Ambiguous {
message: format!(
"step generator starts at {start} which already exceeds \
threshold {threshold} (satisfied at t=0)"
),
});
}
let delta = threshold - start;
let ticks = (delta / step_size).ceil();
let ticks = if ticks <= 0.0 {
1.0
} else if (start + ticks * step_size) <= threshold {
ticks + 1.0
} else {
ticks
};
if let Some(max_val) = max {
if max_val > start {
if max_val <= threshold {
return Err(TimingError::OutOfRange {
message: format!(
"step wraps at {max_val} which is at or below threshold \
{threshold}; the signal never exceeds it"
),
});
}
let crossing_value = start + ticks * step_size;
if crossing_value >= max_val {
return Err(TimingError::OutOfRange {
message: format!(
"step wraps at {max_val} before reaching threshold {threshold}"
),
});
}
}
}
Ok(ticks / rate)
}
}
}
pub fn sequence_crossing_secs(
op: Operator,
threshold: f64,
values: &[f64],
_repeat: Option<bool>,
rate: f64,
) -> Result<f64, TimingError> {
if !rate.is_finite() || rate <= 0.0 {
return Err(TimingError::OutOfRange {
message: format!("sequence rate {rate} must be positive and finite"),
});
}
if values.is_empty() {
return Err(TimingError::OutOfRange {
message: "sequence has no values; no crossing is possible".to_string(),
});
}
for (i, v) in values.iter().enumerate() {
let matches = match op {
Operator::LessThan => *v < threshold,
Operator::GreaterThan => *v > threshold,
};
if matches {
if i == 0 {
return Err(TimingError::Ambiguous {
message: format!(
"sequence starts at {v} which already satisfies \"{op} {threshold}\" \
(satisfied at t=0)"
),
});
}
return Ok((i as f64) / rate);
}
}
Err(TimingError::OutOfRange {
message: format!(
"no value in the sequence satisfies \"{op} {threshold}\"; \
the signal never crosses"
),
})
}
pub fn constant_crossing_secs(
op: Operator,
threshold: f64,
value: f64,
) -> Result<f64, TimingError> {
let satisfied_at_zero = match op {
Operator::LessThan => value < threshold,
Operator::GreaterThan => value > threshold,
};
if satisfied_at_zero {
Err(TimingError::Ambiguous {
message: format!(
"constant generator emits {value}; \"{op} {threshold}\" is satisfied at t=0"
),
})
} else {
Err(TimingError::OutOfRange {
message: format!(
"constant generator emits {value}; it never crosses \"{op} {threshold}\""
),
})
}
}
pub fn sine_crossing_secs() -> Result<f64, TimingError> {
Err(TimingError::Unsupported {
message: "sine generator is not supported as an `after` target: \
sine waves cross any threshold twice per period, \
making the crossing direction ambiguous"
.to_string(),
})
}
pub fn uniform_crossing_secs() -> Result<f64, TimingError> {
Err(TimingError::Unsupported {
message: "uniform generator is not supported as an `after` target: \
the output is non-deterministic, so no crossing time can be computed"
.to_string(),
})
}
pub fn csv_replay_crossing_secs() -> Result<f64, TimingError> {
Err(TimingError::Unsupported {
message: "csv_replay generator is not supported as an `after` target: \
the output depends on external data, so no crossing time can be computed"
.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[derive(Debug, Clone, Copy)]
enum Expect {
Ok(f64),
Ambiguous,
OutOfRange,
Unsupported,
}
#[track_caller]
fn assert_outcome(result: Result<f64, TimingError>, expect: Expect) {
match (result, expect) {
(Ok(actual), Expect::Ok(want)) => {
assert!(
(actual - want).abs() < 1e-9,
"expected Ok({want}), got Ok({actual})"
);
}
(Err(TimingError::Ambiguous { .. }), Expect::Ambiguous) => {}
(Err(TimingError::OutOfRange { .. }), Expect::OutOfRange) => {}
(Err(TimingError::Unsupported { .. }), Expect::Unsupported) => {}
(actual, expect) => {
panic!("expected {expect:?}, got {actual:?}");
}
}
}
#[rustfmt::skip]
#[rstest]
#[case::less_than_one_returns_up_duration( Operator::LessThan, 1.0, 60.0, 30.0, 1.0, 0.0, Expect::Ok(60.0))]
#[case::less_than_half_returns_up_duration( Operator::LessThan, 0.5, 10.0, 5.0, 1.0, 0.0, Expect::Ok(10.0))]
#[case::greater_than_zero_is_ambiguous( Operator::GreaterThan, 0.0, 10.0, 5.0, 1.0, 0.0, Expect::Ambiguous)]
#[case::less_than_zero_is_out_of_range( Operator::LessThan, 0.0, 10.0, 5.0, 1.0, 0.0, Expect::OutOfRange)]
#[case::threshold_above_up_value_less_than_is_ambiguous( Operator::LessThan, 2.0, 10.0, 5.0, 1.0, 0.0, Expect::Ambiguous)]
#[case::threshold_above_up_value_greater_than_out_of_range(Operator::GreaterThan, 2.0, 10.0, 5.0, 1.0, 0.0, Expect::OutOfRange)]
#[case::custom_values( Operator::LessThan, 75.0, 20.0, 10.0, 100.0, 50.0, Expect::Ok(20.0))]
fn flap_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] up_duration: f64,
#[case] down_duration: f64,
#[case] up_value: f64,
#[case] down_value: f64,
#[case] expect: Expect,
) {
let result = flap_crossing_secs(
op,
threshold,
up_duration,
down_duration,
up_value,
down_value,
);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::greater_than_at_midpoint( Operator::GreaterThan, 70.0, 20.0, 85.0, 120.0, Expect::Ok((70.0 - 20.0) / (85.0 - 20.0) * 120.0))]
#[case::greater_than_near_ceiling( Operator::GreaterThan, 84.0, 20.0, 85.0, 120.0, Expect::Ok((84.0 - 20.0) / (85.0 - 20.0) * 120.0))]
#[case::greater_than_at_ceiling_out_of_range( Operator::GreaterThan, 85.0, 20.0, 85.0, 120.0, Expect::OutOfRange)]
#[case::greater_than_below_baseline_ambiguous(Operator::GreaterThan, 10.0, 20.0, 85.0, 120.0, Expect::Ambiguous)]
#[case::less_than_above_ceiling_ambiguous( Operator::LessThan, 100.0, 20.0, 85.0, 120.0, Expect::Ambiguous)]
#[case::less_than_at_baseline_out_of_range( Operator::LessThan, 20.0, 20.0, 85.0, 120.0, Expect::OutOfRange)]
#[case::less_than_midpoint_ambiguous_at_t0( Operator::LessThan, 50.0, 20.0, 85.0, 120.0, Expect::Ambiguous)]
#[case::equal_baseline_ceiling_out_of_range( Operator::GreaterThan, 50.0, 50.0, 50.0, 120.0, Expect::OutOfRange)]
fn sawtooth_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] baseline: f64,
#[case] ceiling: f64,
#[case] period: f64,
#[case] expect: Expect,
) {
let result = sawtooth_crossing_secs(op, threshold, baseline, ceiling, period);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::less_than_returns_spike_duration( Operator::LessThan, 50.0, 0.0, 100.0, 10.0, Expect::Ok(10.0))]
#[case::greater_than_ambiguous_at_t0( Operator::GreaterThan, 50.0, 0.0, 100.0, 10.0, Expect::Ambiguous)]
#[case::less_than_at_baseline_out_of_range( Operator::LessThan, 0.0, 0.0, 100.0, 10.0, Expect::OutOfRange)]
#[case::greater_than_at_peak_out_of_range( Operator::GreaterThan, 100.0, 0.0, 100.0, 10.0, Expect::OutOfRange)]
#[case::less_than_above_peak_ambiguous( Operator::LessThan, 150.0, 0.0, 100.0, 10.0, Expect::Ambiguous)]
#[case::greater_than_below_baseline_ambiguous(Operator::GreaterThan, -10.0, 0.0, 100.0, 10.0, Expect::Ambiguous)]
fn spike_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] baseline: f64,
#[case] peak: f64,
#[case] spike_duration: f64,
#[case] expect: Expect,
) {
let result = spike_crossing_secs(op, threshold, baseline, peak, spike_duration);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::divides_evenly_advances_one_tick(Operator::GreaterThan, 50.0, 0.0, 10.0, None, 1.0, Expect::Ok(6.0))]
#[case::non_divisible_uses_ceil( Operator::GreaterThan, 55.0, 0.0, 10.0, None, 1.0, Expect::Ok(6.0))]
#[case::high_rate_divides_ticks_by_rate( Operator::GreaterThan, 55.0, 0.0, 10.0, None, 2.0, Expect::Ok(3.0))]
#[case::less_than_unsupported( Operator::LessThan, 50.0, 0.0, 10.0, None, 1.0, Expect::Unsupported)]
#[case::start_above_threshold_ambiguous( Operator::GreaterThan, 10.0, 50.0, 5.0, None, 1.0, Expect::Ambiguous)]
#[case::zero_step_size_out_of_range( Operator::GreaterThan, 10.0, 0.0, 0.0, None, 1.0, Expect::OutOfRange)]
#[case::negative_step_size_out_of_range( Operator::GreaterThan, 10.0, 0.0, -1.0, None, 1.0, Expect::OutOfRange)]
#[case::wrap_below_threshold_out_of_range(Operator::GreaterThan, 50.0, 0.0, 10.0, Some(30.0), 1.0, Expect::OutOfRange)]
#[case::wrap_above_threshold_succeeds( Operator::GreaterThan, 25.0, 0.0, 10.0, Some(100.0), 1.0, Expect::Ok(3.0))]
#[case::inactive_max( Operator::GreaterThan, 50.0, 0.0, 10.0, Some(-5.0), 1.0, Expect::Ok(6.0))]
fn step_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] start: f64,
#[case] step_size: f64,
#[case] max: Option<f64>,
#[case] rate: f64,
#[case] expect: Expect,
) {
let result = step_crossing_secs(op, threshold, start, step_size, max, rate);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::greater_than_finds_first_crossing(Operator::GreaterThan, 4.0, &[1.0, 2.0, 5.0, 10.0], Some(true), 1.0, Expect::Ok(2.0))]
#[case::less_than_finds_first_crossing( Operator::LessThan, 2.0, &[10.0, 5.0, 1.0, 0.0], Some(false), 2.0, Expect::Ok(1.0))]
#[case::first_value_matches_ambiguous( Operator::GreaterThan, 0.5, &[1.0, 2.0, 3.0], Some(true), 1.0, Expect::Ambiguous)]
#[case::no_crossing_out_of_range( Operator::GreaterThan, 100.0, &[1.0, 2.0, 3.0], Some(true), 1.0, Expect::OutOfRange)]
#[case::empty_values_out_of_range( Operator::GreaterThan, 0.0, &[], Some(true), 1.0, Expect::OutOfRange)]
fn sequence_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] values: &[f64],
#[case] repeat: Option<bool>,
#[case] rate: f64,
#[case] expect: Expect,
) {
let result = sequence_crossing_secs(op, threshold, values, repeat, rate);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::greater_than_satisfied_ambiguous( Operator::GreaterThan, 10.0, 50.0, Expect::Ambiguous)]
#[case::greater_than_not_satisfied_out_of_range(Operator::GreaterThan, 50.0, 10.0, Expect::OutOfRange)]
#[case::less_than_satisfied_ambiguous( Operator::LessThan, 50.0, 10.0, Expect::Ambiguous)]
#[case::less_than_not_satisfied_out_of_range(Operator::LessThan, 10.0, 50.0, Expect::OutOfRange)]
fn constant_crossing(
#[case] op: Operator,
#[case] threshold: f64,
#[case] value: f64,
#[case] expect: Expect,
) {
let result = constant_crossing_secs(op, threshold, value);
assert_outcome(result, expect);
}
#[rustfmt::skip]
#[rstest]
#[case::steady( "steady", steady_crossing_secs as fn() -> Result<f64, TimingError>)]
#[case::sine( "sine", sine_crossing_secs as fn() -> Result<f64, TimingError>)]
#[case::uniform( "uniform", uniform_crossing_secs as fn() -> Result<f64, TimingError>)]
#[case::csv_replay("csv_replay", csv_replay_crossing_secs as fn() -> Result<f64, TimingError>)]
fn always_unsupported(#[case] name: &str, #[case] f: fn() -> Result<f64, TimingError>) {
let err = f().expect_err("generator should be unsupported");
assert!(matches!(err, TimingError::Unsupported { .. }));
assert!(
err.to_string().contains(name),
"error message should mention '{name}', got: {err}"
);
}
}