Skip to main content

Crate ph_curves

Crate ph_curves 

Source
Expand description

no_std, zero-allocation curve lookup tables, physical transfer functions, and tickless scheduling for embedded Rust.

ph-curves stores pre-computed forward (and optionally inverse) lookup tables as static arrays so that curve evaluation reduces to a single array index. Combined with the tickless scheduler, interrupt-driven firmware can sleep between value transitions instead of polling at a fixed tick rate.

§Quick start

Use the companion CLI (ph-curves-gen) to generate static LUTs from a TOML definition file, then include! the output in your crate:

ⓘ
use ph_curves::{Curve, MonotonicCurve, Tickless, Rounding};

include!("curves.rs");

// Forward evaluation — a single table lookup.
let brightness: u8 = GAMMA_22.eval(input);

// Inverse lookup (monotonic curves only).
let input: u8 = GAMMA_22.inv(brightness);

// Tickless scheduling — sleep until the next quantized value change.
let schedule = EASE_IN_QUAD.tickless_schedule(
    0,     // t0_ms
    1000,  // duration_ms
    0,     // start_val
    255,   // end_val
    10,    // step (quantization)
    Rounding::Nearest,
    0,     // min_dt_ms
);

for deadline in schedule.iter(0) {
    set_timer(deadline.deadline_ms);
    set_output(deadline.current_val);
}

§Key types

Everything is re-exported at the crate root.

§Scope

This crate provides pure mappings and scheduling calculations, not hardware drivers. It never owns or accesses ADCs, GPIO, buses, clocks, timers, interrupts, async runtimes, sensors, or actuators. Callers provide observations and timestamps, then decide how to acquire inputs, schedule wakeups, and apply outputs.

§Temporal stabilization

Filters consume samples supplied by the caller and retain bounded, const-generic state. Sample types are u16, i32, and u32. Windowed filters return FilterOutput::WarmingUp until ready. Stability classification is separate from smoothing so a filtered value is not implicitly treated as settled. Hysteresis and Debounce latch application decisions from sample-count cadence only; they do not live inside TransferFunction and never own GPIO or clocks.

§Post-conversion integer pipelines

Already-converted u32 measurements can enter directly at the temporal stages. This example smooths micro-lux, classifies the independent filtered window, and updates the latch only when that window is stable:

use ph_curves::{
    Hysteresis, MovingAverage, Stability, StabilityDetector, TemporalFilter,
};

let mut average = MovingAverage::<u32, 4>::new();
let mut settled = StabilityDetector::<u32, 3>::new(5_000);
let mut high = Hysteresis::<u32>::new(900_000, 1_000_000);
let mut high_light = false;

// Already-converted micro-lux values supplied by the caller.
for micro_lux in [
    1_010_000, 1_006_000, 1_004_000, 1_002_000, 1_001_000, 999_000,
] {
    let Some(smoothed) = average.update(micro_lux).ready() else {
        continue;
    };
    if matches!(settled.update(smoothed), Stability::Stable { .. }) {
        high_light = high.update(smoothed);
    }
}

assert!(high_light);

For an already-converted i32 measurement, apply caller-supplied calibration before mutating temporal state. An affine overflow can then be handled without inserting a sample into either window:

use ph_curves::{
    AffineTransform, Hysteresis, MovingAverage, Stability, StabilityDetector,
    TemporalFilter,
};

let trim = AffineTransform::new(1_005, -120_000, 1_000).unwrap();
let mut average = MovingAverage::<i32, 3>::new();
let mut settled = StabilityDetector::<i32, 3>::new(100);
let mut fan = Hysteresis::<i32>::new(55_000, 60_000);
let mut fan_on = false;

// Already-converted, untrimmed milli-Celsius values.
for untrimmed in [60_100, 60_080, 60_090, 60_070, 60_080] {
    let corrected = trim.apply(untrimmed).unwrap();
    let Some(smoothed) = average.update(corrected).ready() else {
        continue;
    };
    if matches!(settled.update(smoothed), Stability::Stable { .. }) {
        fan_on = fan.update(smoothed);
    }
}

assert!(fan_on);

The order is deliberate: optional affine correction, smoothing, independent stability classification, then a hysteretic decision. A moving average changes a value, a detector classifies its own recent window, and hysteresis changes its latch only when called. With filter window F and detector window S, the first classification requires F + S - 1 caller-accepted samples. These examples hold the latch during warm-up or instability; resetting it instead is caller policy.

Mapping and filtering do not generally commute. For nonlinear transfers, the two orders can differ even before integer rounding; affine correction can also disagree because each integer stage rounds. Units, cadence, missing/invalid samples, affine errors, reset policy, and hardware action all remain with the caller.

Every stage has bounded inline state and allocates nothing. An AffineTransform stores three i32 coefficients and is O(1). MovingAverage<T, N> stores [T; N], an i64 sum, and indices and is O(1) per sample. StabilityDetector<T, N> has a separate [T; N], threshold, and indices and scans in O(N) per filtered sample. Hysteresis<T> stores two thresholds and latch/reset state and is O(1). Exact byte size and instruction latency are target-dependent.

§Runtime affine calibration

The scalar and wrapper forms are independently fallible, so examples keep their error boundaries explicit rather than relying on unrelated From conversions:

use ph_curves::AffineTransform;

let trim = AffineTransform::new(1_005, -120_000, 1_000).unwrap();
let milli_celsius = 25_000;
let corrected = trim.apply(milli_celsius).unwrap();
let original = trim.unapply(corrected).unwrap();
assert!((original - milli_celsius).abs() <= 1);
use ph_curves::{
    AffineCalibration, InverseTransferFunction, MonotonicDirection,
    PiecewiseLinearTransfer, TransferFunction,
};

static INPUTS: [u16; 2] = [0, 4095];
static OUTPUTS: [i32; 2] = [-40_000, 125_000];
let transfer = PiecewiseLinearTransfer::new(
    &INPUTS,
    &OUTPUTS,
    MonotonicDirection::Increasing,
);
let trimmed =
    AffineCalibration::new(transfer, 1_005, -120_000, 1_000).unwrap();

let milli_celsius = trimmed.convert(2048).unwrap();
let setpoint_code = trimmed.invert(milli_celsius).unwrap();
assert!((i32::from(setpoint_code) - 2048).abs() <= 1);

§Physical measurements

Transfer functions are deliberately separate from normalized curves. The host-only generator may use floating point to fit a physical model, but it emits only u16 input knots and signed i32 output knots. Firmware conversion uses binary search and checked-range i64 interpolation. Formula and empirical-point sources let users describe custom monotonic models without adding sensor-specific runtime code.

The transfer layer is intentionally limited to one static u16 input and one monotonic i32 output, with runtime inverse on the same knots. AffineCalibration applies a caller-supplied integer gain/offset/scale after the table without regenerating knots or touching NVM. The same arithmetic is available as AffineTransform on an already-converted i32 measurement; those coefficients are caller runtime state, not generated-table metadata. The crate does not provide nonmonotonic maps, multidimensional compensation, calibration discovery, sensor fusion, or device policy. Those concerns belong in application or domain-specific crates that compose with this crate’s generic primitives.

ⓘ
use ph_curves::{InverseTransferFunction, TransferFunction};

include!("ntc_transfer.rs");

let milli_celsius = NTC_10K_BETA_3950.convert(adc_code)?;
let setpoint_code = NTC_10K_BETA_3950.invert(25_000)?;

§Code generation (gen-lib feature)

With features = ["gen-lib"], host tools and build.rs can call r#gen::generate_from_toml / r#gen::generate_to_path without shelling out to the CLI. (The module is spelled r#gen because gen is a reserved keyword in Rust 2024; raw identifiers cannot appear in intra-doc links, so these are plain code spans rather than links.)

§The runtime is always no-std and no-alloc

The crate-level no_std attribute is unconditional. It is not relaxed by any feature. The gen-lib / gen-cli features link std only inside src/gen via a module-local extern crate std and explicit imports; they never put std or an allocator on the runtime path, and the crate root does not extern crate std.

This matters because Cargo unifies features across a dependency graph. If the attribute were conditional, one unrelated crate enabling ph-curves/gen-lib would silently turn a firmware build into a std build. Keeping it unconditional makes that impossible rather than merely unlikely. The generator’s String / Vec / format! usage is imported explicitly inside src/gen instead of arriving through the std prelude, so a stray allocation on the runtime path is a compile error.

Modules§

gen
Host-side TOML → Rust code generation for curves and transfers.

Structs§

AffineCalibration
Runtime/factory gain-and-offset wrapper around an inner transfer.
AffineTransform
Invertible i32 affine map y' = (y * gain + offset) / scale.
CurveLut
A curve backed by an N-entry forward and optional M-entry inverse LUT.
Debounce
Sample-count contact debounce for boolean inputs.
ExponentialSmoother
Constant-memory exponential smoother.
Hysteresis
Schmitt-trigger latch over integer samples.
MedianFilter
Fixed-window median filter for small odd window sizes.
MonotonicCurveLut
A monotonic curve backed by an N-entry forward and M-entry inverse LUT.
MovingAverage
Exact fixed-window moving average.
ObservationGuard
One explicitly declared observation code and the policy applied to it.
ObservationGuardMetadata
Compact facts for a transfer’s optional observation-code guard.
PiecewiseLinearTransfer
A sparse, nonuniform, piecewise-linear u16 to i32 transfer function.
StabilityDetector
Fixed-window range-based stability detector.
TicklessDeadline
A single output produced by the tickless scheduler.
TicklessIter
Iterator over successive TicklessDeadline values produced by a TicklessSchedule.
TicklessSchedule
A tickless schedule bound to a monotonic curve and segment parameters.
TransferMetadata
Compact facts recorded by the host generator for a transfer table.

Enums§

AffineCalibrationError
Error returned when affine calibration constants are invalid.
AffineOverflow
Error returned when affine arithmetic cannot be represented in i32.
AffineTransformError
Error returned when affine transform coefficients are invalid.
BoundaryBehavior
Behavior for observations outside one side of a transfer domain.
DebounceOutput
Output from a sample-count Debounce.
FilterOutput
Output from a temporal filter.
FlatResolution
How to resolve a physical value that lands on a flat (non-unique) output run.
InterpolationError
Error returned for invalid standalone segment interpolation arguments.
InverseTransferError
Error returned when a physical value cannot be inverted uniquely.
MonotonicDirection
Monotonic direction of a transfer function’s output.
ObservationGuardBehavior
Policy applied when one explicitly declared observation code is seen.
RepeatMode
Repeat behaviour for a tickless schedule.
Rounding
Rounding policy used by quantize and the tickless scheduler.
Stability
Classification returned by a StabilityDetector.
TransferError
Error returned when an observation is outside a transfer domain, a declared observation guard rejects it, or affine calibration arithmetic cannot be represented.

Traits§

Curve
Curve evaluation trait.
InverseTransferFunction
A conversion from a physical measurement back to an observation.
MonotonicCurve
Trait for monotonic curves that can be inverted.
TemporalFilter
Common interface for caller-driven temporal filters.
TemporalSample
Integer sample type supported by temporal stabilization primitives.
Tickless
Extension trait that adds tickless scheduling to any MonotonicCurve<T, T> where T: UnitValue.
TransferFunction
A conversion from an observation to a measurement.
UnitValue
A normalized value type usable as a curve domain / range.

Functions§

interpolate_segment
Interpolate one signed integer segment with nearest, ties-away rounding.
invert_segment
Invert one signed integer segment with nearest, ties-away rounding.
lerp_u8
Linearly interpolate between a and b with a u8 blend weight.
lerp_u16
Linearly interpolate between two u16 values with a u8 blend weight.
map_u8_to_u16
Scale a normalized u8 value (0..=255) into a u16 range 0..=max.
next_target_value
Return the next quantized target value one step closer to end.
quantize
Snap value to the nearest multiple of step according to rounding.

Type Aliases§

CurveLut256
A 256-entry curve lookup table (u8 domain and range).
CurveLut65536
A 65536-entry curve lookup table (u16 domain and range).
MonotonicCurveLut256
A 256-entry monotonic curve lookup table (u8 domain and range).
MonotonicCurveLut65536
A 65536-entry monotonic curve lookup table (u16 domain and range).