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.
- Curves —
Curve/MonotonicCurvetraits and the LUT-backedCurveLut/MonotonicCurveLuttypes. - Tickless scheduling —
Ticklessextension trait,TicklessSchedule, and theTicklessIteriterator. - Physical transfer functions —
TransferFunction/InverseTransferFunctionand the sparse, integer-onlyPiecewiseLinearTransferfor ADC ↔ measurement conversion, plusAffineCalibrationfor caller-supplied gain/offset after a transfer andAffineTransformfor the same arithmetic on an existingi32. - Temporal stabilization —
MovingAverage,MedianFilter,ExponentialSmoother,StabilityDetector,Hysteresis, andDebounceover caller-supplied integer samples. - Math helpers —
UnitValuetrait,lerp_u8,lerp_u16,map_u8_to_u16,quantize, andnext_target_value.
§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§
- Affine
Calibration - Runtime/factory gain-and-offset wrapper around an inner transfer.
- Affine
Transform - Invertible
i32affine mapy' = (y * gain + offset) / scale. - Curve
Lut - A curve backed by an
N-entry forward and optionalM-entry inverse LUT. - Debounce
- Sample-count contact debounce for boolean inputs.
- Exponential
Smoother - Constant-memory exponential smoother.
- Hysteresis
- Schmitt-trigger latch over integer samples.
- Median
Filter - Fixed-window median filter for small odd window sizes.
- Monotonic
Curve Lut - A monotonic curve backed by an
N-entry forward andM-entry inverse LUT. - Moving
Average - Exact fixed-window moving average.
- Observation
Guard - One explicitly declared observation code and the policy applied to it.
- Observation
Guard Metadata - Compact facts for a transfer’s optional observation-code guard.
- Piecewise
Linear Transfer - A sparse, nonuniform, piecewise-linear
u16toi32transfer function. - Stability
Detector - Fixed-window range-based stability detector.
- Tickless
Deadline - A single output produced by the tickless scheduler.
- Tickless
Iter - Iterator over successive
TicklessDeadlinevalues produced by aTicklessSchedule. - Tickless
Schedule - A tickless schedule bound to a monotonic curve and segment parameters.
- Transfer
Metadata - Compact facts recorded by the host generator for a transfer table.
Enums§
- Affine
Calibration Error - Error returned when affine calibration constants are invalid.
- Affine
Overflow - Error returned when affine arithmetic cannot be represented in
i32. - Affine
Transform Error - Error returned when affine transform coefficients are invalid.
- Boundary
Behavior - Behavior for observations outside one side of a transfer domain.
- Debounce
Output - Output from a sample-count
Debounce. - Filter
Output - Output from a temporal filter.
- Flat
Resolution - How to resolve a physical value that lands on a flat (non-unique) output run.
- Interpolation
Error - Error returned for invalid standalone segment interpolation arguments.
- Inverse
Transfer Error - Error returned when a physical value cannot be inverted uniquely.
- Monotonic
Direction - Monotonic direction of a transfer function’s output.
- Observation
Guard Behavior - Policy applied when one explicitly declared observation code is seen.
- Repeat
Mode - Repeat behaviour for a tickless schedule.
- Rounding
- Rounding policy used by
quantizeand the tickless scheduler. - Stability
- Classification returned by a
StabilityDetector. - Transfer
Error - 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.
- Inverse
Transfer Function - A conversion from a physical measurement back to an observation.
- Monotonic
Curve - Trait for monotonic curves that can be inverted.
- Temporal
Filter - Common interface for caller-driven temporal filters.
- Temporal
Sample - Integer sample type supported by temporal stabilization primitives.
- Tickless
- Extension trait that adds tickless scheduling to any
MonotonicCurve<T, T>whereT: UnitValue. - Transfer
Function - A conversion from an observation to a measurement.
- Unit
Value - 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
aandbwith au8blend weight. - lerp_
u16 - Linearly interpolate between two
u16values with au8blend weight. - map_
u8_ to_ u16 - Scale a normalized
u8value (0..=255) into au16range0..=max. - next_
target_ value - Return the next quantized target value one
stepcloser toend. - quantize
- Snap
valueto the nearest multiple ofstepaccording torounding.
Type Aliases§
- Curve
Lut256 - A 256-entry curve lookup table (u8 domain and range).
- Curve
Lut65536 - A 65536-entry curve lookup table (u16 domain and range).
- Monotonic
Curve Lut256 - A 256-entry monotonic curve lookup table (u8 domain and range).
- Monotonic
Curve Lut65536 - A 65536-entry monotonic curve lookup table (u16 domain and range).