Skip to main content

pamoja_ffi/
kit.rs

1//! The C ABI for the goal-named helper math.
2//!
3//! These functions wrap [`pamoja_kit`] for callers that reach the SDK through the
4//! flat C boundary: smoothing a noisy reading, holding a value with a PID,
5//! switching a load with hysteresis, warning before a tank runs dry, and placing
6//! a fix against a geofence.
7//!
8//! The helpers are infallible pure math, so unlike the transport capabilities
9//! nothing here returns a status for the work itself. Each stateful helper is an
10//! opaque handle whose methods document what they return for a null handle,
11//! following `pamoja_mqtt_client_is_connected`. A constructor never returns null,
12//! except a windowed helper's `_with_capacity` constructor given a capacity it
13//! cannot keep. Helpers that answer "maybe" return a `bool` and write the value
14//! through an out-parameter.
15//!
16//! A reading that is not a finite number is ignored by every helper that keeps
17//! state, as the Rust crate documents, and an anomaly detector flags it.
18
19use std::ptr;
20
21use pamoja_kit::{
22    deadband, imu, units, weather, Anomaly, Boundary, Calibration, Complementary, Coordinate,
23    Debounce, Depletion, Edge, Geofence, Kalman, Median, Pid, Ramp, Smoother, Surge, Thermostat,
24    Trend, Trigger, Window,
25};
26
27/// A latitude and longitude in degrees.
28#[repr(C)]
29#[derive(Clone, Copy)]
30pub struct PamojaCoordinate {
31    /// Degrees north of the equator, negative for south.
32    pub latitude: f64,
33    /// Degrees east of the prime meridian, negative for west.
34    pub longitude: f64,
35}
36
37impl From<PamojaCoordinate> for Coordinate {
38    fn from(value: PamojaCoordinate) -> Self {
39        Coordinate::new(value.latitude, value.longitude)
40    }
41}
42
43/// Where a fix sits relative to a geofence, including the moment it crosses.
44#[repr(C)]
45#[derive(Clone, Copy, PartialEq, Eq, Debug)]
46pub enum PamojaBoundary {
47    /// The fix is inside the fence and was inside before, or is the first fix inside.
48    Inside = 0,
49    /// The fix is outside the fence and was outside before, or is the first fix outside.
50    Outside = 1,
51    /// The fix just crossed from inside to outside: the moment to raise a breach alert.
52    Exited = 2,
53    /// The fix just crossed from outside back inside.
54    Entered = 3,
55}
56
57impl From<Boundary> for PamojaBoundary {
58    fn from(value: Boundary) -> Self {
59        match value {
60            Boundary::Inside => Self::Inside,
61            Boundary::Outside => Self::Outside,
62            Boundary::Exited => Self::Exited,
63            Boundary::Entered => Self::Entered,
64        }
65    }
66}
67
68// Each handle below is written out rather than generated by a macro: cbindgen
69// parses this source without expanding macros, so a macro-defined type would be
70// referenced by the header without ever being declared in it.
71
72/// An opaque handle to an exponential smoother.
73pub struct PamojaSmoother {
74    inner: Smoother,
75}
76
77/// Releases a smoother handle.
78///
79/// Passing null is a no-op.
80///
81/// # Safety
82///
83/// `smoother` must be a handle from [`pamoja_smoother_new`] that has not already been
84/// freed, or null. After this call it must not be used again.
85#[no_mangle]
86pub unsafe extern "C" fn pamoja_smoother_free(smoother: *mut PamojaSmoother) {
87    if !smoother.is_null() {
88        drop(Box::from_raw(smoother));
89    }
90}
91
92/// An opaque handle to a PID controller.
93pub struct PamojaPid {
94    inner: Pid,
95}
96
97/// Releases a PID controller handle.
98///
99/// Passing null is a no-op.
100///
101/// # Safety
102///
103/// `pid` must be a handle from a PID constructor that has not already been
104/// freed, or null. After this call it must not be used again.
105#[no_mangle]
106pub unsafe extern "C" fn pamoja_pid_free(pid: *mut PamojaPid) {
107    if !pid.is_null() {
108        drop(Box::from_raw(pid));
109    }
110}
111
112/// An opaque handle to an on/off controller with hysteresis.
113pub struct PamojaThermostat {
114    inner: Thermostat,
115}
116
117/// What a trigger reports when a reading changes its state, if anything.
118#[repr(C)]
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum PamojaEdge {
121    /// Nothing changed.
122    None = 0,
123    /// The reading just crossed the line: the condition became true.
124    Set = 1,
125    /// The reading just came back past the release band: the condition stopped holding.
126    Cleared = 2,
127}
128
129/// An opaque handle to a threshold with hysteresis that reports its edges.
130pub struct PamojaTrigger {
131    inner: Trigger,
132}
133
134/// Releases a trigger handle.
135///
136/// Passing null is a no-op.
137///
138/// # Safety
139///
140/// `trigger` must be a handle from [`pamoja_trigger_above`] or [`pamoja_trigger_below`]
141/// that has not already been freed, or null. After this call it must not be used again.
142#[no_mangle]
143pub unsafe extern "C" fn pamoja_trigger_free(trigger: *mut PamojaTrigger) {
144    if !trigger.is_null() {
145        drop(Box::from_raw(trigger));
146    }
147}
148
149/// Creates a trigger that fires when a reading rises above the line and clears once it
150/// has fallen below the line by the hysteresis.
151///
152/// # Returns
153///
154/// A handle the caller must release with [`pamoja_trigger_free`].
155///
156/// # Safety
157///
158/// The returned handle must be freed exactly once.
159#[no_mangle]
160pub unsafe extern "C" fn pamoja_trigger_above(
161    threshold: f32,
162    hysteresis: f32,
163) -> *mut PamojaTrigger {
164    Box::into_raw(Box::new(PamojaTrigger {
165        inner: Trigger::above(threshold, hysteresis),
166    }))
167}
168
169/// Creates a trigger that fires when a reading falls below the line and clears once it
170/// has risen above the line by the hysteresis.
171///
172/// # Returns
173///
174/// A handle the caller must release with [`pamoja_trigger_free`].
175///
176/// # Safety
177///
178/// The returned handle must be freed exactly once.
179#[no_mangle]
180pub unsafe extern "C" fn pamoja_trigger_below(
181    threshold: f32,
182    hysteresis: f32,
183) -> *mut PamojaTrigger {
184    Box::into_raw(Box::new(PamojaTrigger {
185        inner: Trigger::below(threshold, hysteresis),
186    }))
187}
188
189/// Feeds a reading to a trigger and returns the edge it caused.
190///
191/// # Returns
192///
193/// [`PamojaEdge::Set`] or [`PamojaEdge::Cleared`] the moment the state changes, and
194/// [`PamojaEdge::None`] while nothing changed or if `trigger` is null.
195///
196/// # Safety
197///
198/// `trigger` must be a live handle from a trigger constructor, or null.
199#[no_mangle]
200pub unsafe extern "C" fn pamoja_trigger_update(
201    trigger: *mut PamojaTrigger,
202    reading: f32,
203) -> PamojaEdge {
204    match trigger.as_mut() {
205        Some(trigger) => match trigger.inner.update(reading) {
206            Some(Edge::Set) => PamojaEdge::Set,
207            Some(Edge::Cleared) => PamojaEdge::Cleared,
208            None => PamojaEdge::None,
209        },
210        None => PamojaEdge::None,
211    }
212}
213
214/// Reports whether a trigger's condition currently holds.
215///
216/// # Returns
217///
218/// `true` from the reading that set the trigger until the one that clears it, or
219/// `false` if `trigger` is null.
220///
221/// # Safety
222///
223/// `trigger` must be a live handle from a trigger constructor, or null.
224#[no_mangle]
225pub unsafe extern "C" fn pamoja_trigger_is_set(trigger: *const PamojaTrigger) -> bool {
226    match trigger.as_ref() {
227        Some(trigger) => trigger.inner.is_set(),
228        None => false,
229    }
230}
231
232/// Reads the line a trigger watches.
233///
234/// # Returns
235///
236/// The threshold it was created with, or NaN if `trigger` is null.
237///
238/// # Safety
239///
240/// `trigger` must be a live handle from a trigger constructor, or null.
241#[no_mangle]
242pub unsafe extern "C" fn pamoja_trigger_threshold(trigger: *const PamojaTrigger) -> f32 {
243    match trigger.as_ref() {
244        Some(trigger) => trigger.inner.threshold(),
245        None => f32::NAN,
246    }
247}
248
249/// Reads the release band on the far side of a trigger's line.
250///
251/// # Returns
252///
253/// The hysteresis it was created with, as a magnitude, or NaN if `trigger` is null.
254///
255/// # Safety
256///
257/// `trigger` must be a live handle from a trigger constructor, or null.
258#[no_mangle]
259pub unsafe extern "C" fn pamoja_trigger_hysteresis(trigger: *const PamojaTrigger) -> f32 {
260    match trigger.as_ref() {
261        Some(trigger) => trigger.inner.hysteresis(),
262        None => f32::NAN,
263    }
264}
265
266/// Reports whether a trigger watches a rising reading.
267///
268/// # Returns
269///
270/// `true` for a trigger from [`pamoja_trigger_above`], and `false` for one from
271/// [`pamoja_trigger_below`] or if `trigger` is null.
272///
273/// # Safety
274///
275/// `trigger` must be a live handle from a trigger constructor, or null.
276#[no_mangle]
277pub unsafe extern "C" fn pamoja_trigger_watches_above(trigger: *const PamojaTrigger) -> bool {
278    match trigger.as_ref() {
279        Some(trigger) => trigger.inner.watches_above(),
280        None => false,
281    }
282}
283
284/// Releases a thermostat handle.
285///
286/// Passing null is a no-op.
287///
288/// # Safety
289///
290/// `thermostat` must be a handle from a thermostat constructor that has not already been
291/// freed, or null. After this call it must not be used again.
292#[no_mangle]
293pub unsafe extern "C" fn pamoja_thermostat_free(thermostat: *mut PamojaThermostat) {
294    if !thermostat.is_null() {
295        drop(Box::from_raw(thermostat));
296    }
297}
298
299/// An opaque handle to a depletion estimator.
300pub struct PamojaDepletion {
301    inner: Depletion,
302}
303
304/// Releases a depletion estimator handle.
305///
306/// Passing null is a no-op.
307///
308/// # Safety
309///
310/// `depletion` must be a handle from [`pamoja_depletion_new`] that has not already been
311/// freed, or null. After this call it must not be used again.
312#[no_mangle]
313pub unsafe extern "C" fn pamoja_depletion_free(depletion: *mut PamojaDepletion) {
314    if !depletion.is_null() {
315        drop(Box::from_raw(depletion));
316    }
317}
318
319/// An opaque handle to a one-dimensional Kalman filter.
320pub struct PamojaKalman {
321    inner: Kalman,
322}
323
324/// Releases a Kalman filter handle.
325///
326/// Passing null is a no-op.
327///
328/// # Safety
329///
330/// `kalman` must be a handle from [`pamoja_kalman_new`] that has not already been
331/// freed, or null. After this call it must not be used again.
332#[no_mangle]
333pub unsafe extern "C" fn pamoja_kalman_free(kalman: *mut PamojaKalman) {
334    if !kalman.is_null() {
335        drop(Box::from_raw(kalman));
336    }
337}
338
339/// An opaque handle to a boolean debouncer.
340pub struct PamojaDebounce {
341    inner: Debounce,
342}
343
344/// Releases a debouncer handle.
345///
346/// Passing null is a no-op.
347///
348/// # Safety
349///
350/// `debounce` must be a handle from [`pamoja_debounce_new`] that has not already been
351/// freed, or null. After this call it must not be used again.
352#[no_mangle]
353pub unsafe extern "C" fn pamoja_debounce_free(debounce: *mut PamojaDebounce) {
354    if !debounce.is_null() {
355        drop(Box::from_raw(debounce));
356    }
357}
358
359/// An opaque handle to a rate limiter.
360pub struct PamojaRamp {
361    inner: Ramp,
362}
363
364/// Releases a rate limiter handle.
365///
366/// Passing null is a no-op.
367///
368/// # Safety
369///
370/// `ramp` must be a handle from [`pamoja_ramp_new`] that has not already been
371/// freed, or null. After this call it must not be used again.
372#[no_mangle]
373pub unsafe extern "C" fn pamoja_ramp_free(ramp: *mut PamojaRamp) {
374    if !ramp.is_null() {
375        drop(Box::from_raw(ramp));
376    }
377}
378
379/// An opaque handle to a step-change detector.
380pub struct PamojaSurge {
381    inner: Surge,
382}
383
384/// Releases a surge detector handle.
385///
386/// Passing null is a no-op.
387///
388/// # Safety
389///
390/// `surge` must be a handle from a surge constructor that has not already been
391/// freed, or null. After this call it must not be used again.
392#[no_mangle]
393pub unsafe extern "C" fn pamoja_surge_free(surge: *mut PamojaSurge) {
394    if !surge.is_null() {
395        drop(Box::from_raw(surge));
396    }
397}
398
399/// An opaque handle to a raw-to-units calibration.
400pub struct PamojaCalibration {
401    inner: Calibration,
402}
403
404/// Releases a calibration handle.
405///
406/// Passing null is a no-op.
407///
408/// # Safety
409///
410/// `calibration` must be a handle from a calibration constructor that has not already been
411/// freed, or null. After this call it must not be used again.
412#[no_mangle]
413pub unsafe extern "C" fn pamoja_calibration_free(calibration: *mut PamojaCalibration) {
414    if !calibration.is_null() {
415        drop(Box::from_raw(calibration));
416    }
417}
418
419/// An opaque handle to a circular geofence.
420pub struct PamojaGeofence {
421    inner: Geofence,
422}
423
424/// Releases a geofence handle.
425///
426/// Passing null is a no-op.
427///
428/// # Safety
429///
430/// `geofence` must be a handle from [`pamoja_geofence_new`] that has not already been
431/// freed, or null. After this call it must not be used again.
432#[no_mangle]
433pub unsafe extern "C" fn pamoja_geofence_free(geofence: *mut PamojaGeofence) {
434    if !geofence.is_null() {
435        drop(Box::from_raw(geofence));
436    }
437}
438
439/// Creates an exponential smoother.
440///
441/// # Returns
442///
443/// A handle the caller must release with [`pamoja_smoother_free`].
444///
445/// # Safety
446///
447/// The returned handle must be freed exactly once.
448#[no_mangle]
449pub unsafe extern "C" fn pamoja_smoother_new(weight: f32) -> *mut PamojaSmoother {
450    Box::into_raw(Box::new(PamojaSmoother {
451        inner: Smoother::new(weight),
452    }))
453}
454
455/// Folds a sample into a smoother and returns the smoothed value.
456///
457/// # Returns
458///
459/// The smoothed value, or NaN if `smoother` is null.
460///
461/// # Safety
462///
463/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null.
464#[no_mangle]
465pub unsafe extern "C" fn pamoja_smoother_update(smoother: *mut PamojaSmoother, sample: f32) -> f32 {
466    match smoother.as_mut() {
467        Some(smoother) => smoother.inner.update(sample),
468        None => f32::NAN,
469    }
470}
471
472/// Reads a smoother's current value without folding in a sample.
473///
474/// # Returns
475///
476/// `true` if a value is available, having written it to `out_value`; `false` if
477/// no sample has been seen yet or `smoother` is null.
478///
479/// # Safety
480///
481/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null, and
482/// `out_value` must point to a writable `float` or be null.
483#[no_mangle]
484pub unsafe extern "C" fn pamoja_smoother_value(
485    smoother: *const PamojaSmoother,
486    out_value: *mut f32,
487) -> bool {
488    let Some(smoother) = smoother.as_ref() else {
489        return false;
490    };
491    write_some(smoother.inner.value(), out_value)
492}
493
494/// Clears a smoother back to its initial state.
495///
496/// # Safety
497///
498/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null.
499#[no_mangle]
500pub unsafe extern "C" fn pamoja_smoother_reset(smoother: *mut PamojaSmoother) {
501    if let Some(smoother) = smoother.as_mut() {
502        smoother.inner.reset();
503    }
504}
505
506/// Creates a PID controller with the given gains and no output limits.
507///
508/// # Returns
509///
510/// A handle the caller must release with [`pamoja_pid_free`].
511///
512/// # Safety
513///
514/// The returned handle must be freed exactly once.
515#[no_mangle]
516pub unsafe extern "C" fn pamoja_pid_new(kp: f32, ki: f32, kd: f32) -> *mut PamojaPid {
517    Box::into_raw(Box::new(PamojaPid {
518        inner: Pid::new(kp, ki, kd),
519    }))
520}
521
522/// Creates a PID controller whose output is clamped to `[min, max]`.
523///
524/// Either side may be infinite, or NaN, to leave it open; a `max` below `min` is
525/// swapped with it.
526///
527/// # Returns
528///
529/// A handle the caller must release with [`pamoja_pid_free`].
530///
531/// # Safety
532///
533/// The returned handle must be freed exactly once.
534#[no_mangle]
535pub unsafe extern "C" fn pamoja_pid_new_with_limits(
536    kp: f32,
537    ki: f32,
538    kd: f32,
539    min: f32,
540    max: f32,
541) -> *mut PamojaPid {
542    Box::into_raw(Box::new(PamojaPid {
543        inner: Pid::new(kp, ki, kd).with_limits(min, max),
544    }))
545}
546
547/// Advances a PID controller by one step.
548///
549/// `dt` is the time since the previous step, in the unit the integral and derivative
550/// gains assume; one at or below zero, or not a finite number, skips the integral and
551/// derivative. A setpoint or measurement that is not a finite number is ignored and the
552/// previous output returned.
553///
554/// # Returns
555///
556/// The control output, or NaN if `pid` is null.
557///
558/// # Safety
559///
560/// `pid` must be a live handle from a PID constructor, or null.
561#[no_mangle]
562pub unsafe extern "C" fn pamoja_pid_update(
563    pid: *mut PamojaPid,
564    setpoint: f32,
565    measurement: f32,
566    dt: f32,
567) -> f32 {
568    match pid.as_mut() {
569        Some(pid) => pid.inner.update(setpoint, measurement, dt),
570        None => f32::NAN,
571    }
572}
573
574/// Clears a PID controller's accumulated integral, its last error, and the output it
575/// last returned.
576///
577/// # Safety
578///
579/// `pid` must be a live handle from a PID constructor, or null.
580#[no_mangle]
581pub unsafe extern "C" fn pamoja_pid_reset(pid: *mut PamojaPid) {
582    if let Some(pid) = pid.as_mut() {
583        pid.inner.reset();
584    }
585}
586
587/// Creates a cooling thermostat, which switches on when the reading rises.
588///
589/// # Returns
590///
591/// A handle the caller must release with [`pamoja_thermostat_free`].
592///
593/// # Safety
594///
595/// The returned handle must be freed exactly once.
596#[no_mangle]
597pub unsafe extern "C" fn pamoja_thermostat_cooling(
598    setpoint: f32,
599    hysteresis: f32,
600) -> *mut PamojaThermostat {
601    Box::into_raw(Box::new(PamojaThermostat {
602        inner: Thermostat::cooling(setpoint, hysteresis),
603    }))
604}
605
606/// Creates a heating thermostat, which switches on when the reading falls.
607///
608/// # Returns
609///
610/// A handle the caller must release with [`pamoja_thermostat_free`].
611///
612/// # Safety
613///
614/// The returned handle must be freed exactly once.
615#[no_mangle]
616pub unsafe extern "C" fn pamoja_thermostat_heating(
617    setpoint: f32,
618    hysteresis: f32,
619) -> *mut PamojaThermostat {
620    Box::into_raw(Box::new(PamojaThermostat {
621        inner: Thermostat::heating(setpoint, hysteresis),
622    }))
623}
624
625/// Feeds a reading to a thermostat and returns whether the load should be on.
626///
627/// # Returns
628///
629/// `true` while the load should run, or `false` if `thermostat` is null.
630///
631/// # Safety
632///
633/// `thermostat` must be a live handle from a thermostat constructor, or null.
634#[no_mangle]
635pub unsafe extern "C" fn pamoja_thermostat_update(
636    thermostat: *mut PamojaThermostat,
637    reading: f32,
638) -> bool {
639    match thermostat.as_mut() {
640        Some(thermostat) => thermostat.inner.update(reading),
641        None => false,
642    }
643}
644
645/// Reports a thermostat's current output without feeding it a reading.
646///
647/// # Returns
648///
649/// `true` while the load should run, or `false` if `thermostat` is null.
650///
651/// # Safety
652///
653/// `thermostat` must be a live handle from a thermostat constructor, or null.
654#[no_mangle]
655pub unsafe extern "C" fn pamoja_thermostat_is_on(thermostat: *const PamojaThermostat) -> bool {
656    match thermostat.as_ref() {
657        Some(thermostat) => thermostat.inner.is_on(),
658        None => false,
659    }
660}
661
662/// Creates a depletion estimator that warns as a level approaches `threshold`.
663///
664/// # Returns
665///
666/// A handle the caller must release with [`pamoja_depletion_free`].
667///
668/// # Safety
669///
670/// The returned handle must be freed exactly once.
671#[no_mangle]
672pub unsafe extern "C" fn pamoja_depletion_new(threshold: f32) -> *mut PamojaDepletion {
673    Box::into_raw(Box::new(PamojaDepletion {
674        inner: Depletion::new(threshold),
675    }))
676}
677
678/// Records a level and estimates how many samples remain before the threshold.
679///
680/// # Returns
681///
682/// `true` if an estimate is available, having written it to `out_samples`: 0 once
683/// the level is at or below the threshold, the first reading included. `false` if
684/// the level is steady or rising, on a first reading above the threshold, when no
685/// rate is known yet, for a level that is not a finite number, or if `depletion`
686/// is null.
687///
688/// # Safety
689///
690/// `depletion` must be a live handle from [`pamoja_depletion_new`], or null, and
691/// `out_samples` must point to a writable `uint32_t` or be null.
692#[no_mangle]
693pub unsafe extern "C" fn pamoja_depletion_update(
694    depletion: *mut PamojaDepletion,
695    level: f32,
696    out_samples: *mut u32,
697) -> bool {
698    let Some(depletion) = depletion.as_mut() else {
699        return false;
700    };
701    write_some(depletion.inner.update(level), out_samples)
702}
703
704/// Creates a one-dimensional Kalman filter.
705///
706/// # Returns
707///
708/// A handle the caller must release with [`pamoja_kalman_free`].
709///
710/// # Safety
711///
712/// The returned handle must be freed exactly once.
713#[no_mangle]
714pub unsafe extern "C" fn pamoja_kalman_new(
715    process_noise: f32,
716    measurement_noise: f32,
717    initial: f32,
718) -> *mut PamojaKalman {
719    Box::into_raw(Box::new(PamojaKalman {
720        inner: Kalman::new(process_noise, measurement_noise, initial),
721    }))
722}
723
724/// Folds a reading into a Kalman filter and returns the new estimate.
725///
726/// # Returns
727///
728/// The updated estimate, or NaN if `kalman` is null.
729///
730/// # Safety
731///
732/// `kalman` must be a live handle from [`pamoja_kalman_new`], or null.
733#[no_mangle]
734pub unsafe extern "C" fn pamoja_kalman_update(kalman: *mut PamojaKalman, reading: f32) -> f32 {
735    match kalman.as_mut() {
736        Some(kalman) => kalman.inner.update(reading),
737        None => f32::NAN,
738    }
739}
740
741/// Reads a Kalman filter's current estimate without folding in a reading.
742///
743/// # Returns
744///
745/// The current estimate, or NaN if `kalman` is null.
746///
747/// # Safety
748///
749/// `kalman` must be a live handle from [`pamoja_kalman_new`], or null.
750#[no_mangle]
751pub unsafe extern "C" fn pamoja_kalman_estimate(kalman: *const PamojaKalman) -> f32 {
752    match kalman.as_ref() {
753        Some(kalman) => kalman.inner.estimate(),
754        None => f32::NAN,
755    }
756}
757
758/// Creates a debouncer that requires `samples` agreeing readings to change state.
759///
760/// # Returns
761///
762/// A handle the caller must release with [`pamoja_debounce_free`].
763///
764/// # Safety
765///
766/// The returned handle must be freed exactly once.
767#[no_mangle]
768pub unsafe extern "C" fn pamoja_debounce_new(samples: u16, initial: bool) -> *mut PamojaDebounce {
769    Box::into_raw(Box::new(PamojaDebounce {
770        inner: Debounce::new(samples, initial),
771    }))
772}
773
774/// Feeds a raw reading to a debouncer and returns the settled state.
775///
776/// # Returns
777///
778/// The debounced state, or `false` if `debounce` is null.
779///
780/// # Safety
781///
782/// `debounce` must be a live handle from [`pamoja_debounce_new`], or null.
783#[no_mangle]
784pub unsafe extern "C" fn pamoja_debounce_update(debounce: *mut PamojaDebounce, raw: bool) -> bool {
785    match debounce.as_mut() {
786        Some(debounce) => debounce.inner.update(raw),
787        None => false,
788    }
789}
790
791/// Reports a debouncer's settled state without feeding it a reading.
792///
793/// # Returns
794///
795/// The debounced state, or `false` if `debounce` is null.
796///
797/// # Safety
798///
799/// `debounce` must be a live handle from [`pamoja_debounce_new`], or null.
800#[no_mangle]
801pub unsafe extern "C" fn pamoja_debounce_state(debounce: *const PamojaDebounce) -> bool {
802    match debounce.as_ref() {
803        Some(debounce) => debounce.inner.state(),
804        None => false,
805    }
806}
807
808/// Creates a rate limiter starting at `start` and moving at most `max_step`.
809///
810/// # Returns
811///
812/// A handle the caller must release with [`pamoja_ramp_free`].
813///
814/// # Safety
815///
816/// The returned handle must be freed exactly once.
817#[no_mangle]
818pub unsafe extern "C" fn pamoja_ramp_new(start: f32, max_step: f32) -> *mut PamojaRamp {
819    Box::into_raw(Box::new(PamojaRamp {
820        inner: Ramp::new(start, max_step),
821    }))
822}
823
824/// Moves a ramp one step toward `target` and returns the new value.
825///
826/// # Returns
827///
828/// The rate-limited value, or NaN if `ramp` is null.
829///
830/// # Safety
831///
832/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
833#[no_mangle]
834pub unsafe extern "C" fn pamoja_ramp_update(ramp: *mut PamojaRamp, target: f32) -> f32 {
835    match ramp.as_mut() {
836        Some(ramp) => ramp.inner.update(target),
837        None => f32::NAN,
838    }
839}
840
841/// Reads a ramp's current value.
842///
843/// # Returns
844///
845/// The current value, or NaN if `ramp` is null.
846///
847/// # Safety
848///
849/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
850#[no_mangle]
851pub unsafe extern "C" fn pamoja_ramp_value(ramp: *const PamojaRamp) -> f32 {
852    match ramp.as_ref() {
853        Some(ramp) => ramp.inner.value(),
854        None => f32::NAN,
855    }
856}
857
858/// Forces a ramp to a value without rate limiting.
859///
860/// # Safety
861///
862/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
863#[no_mangle]
864pub unsafe extern "C" fn pamoja_ramp_set(ramp: *mut PamojaRamp, value: f32) {
865    if let Some(ramp) = ramp.as_mut() {
866        ramp.inner.set(value);
867    }
868}
869
870/// Creates a detector for rises of more than `limit` between readings.
871///
872/// # Returns
873///
874/// A handle the caller must release with [`pamoja_surge_free`].
875///
876/// # Safety
877///
878/// The returned handle must be freed exactly once.
879#[no_mangle]
880pub unsafe extern "C" fn pamoja_surge_rising(limit: f32) -> *mut PamojaSurge {
881    Box::into_raw(Box::new(PamojaSurge {
882        inner: Surge::rising(limit),
883    }))
884}
885
886/// Creates a detector for falls of more than `limit` between readings.
887///
888/// # Returns
889///
890/// A handle the caller must release with [`pamoja_surge_free`].
891///
892/// # Safety
893///
894/// The returned handle must be freed exactly once.
895#[no_mangle]
896pub unsafe extern "C" fn pamoja_surge_falling(limit: f32) -> *mut PamojaSurge {
897    Box::into_raw(Box::new(PamojaSurge {
898        inner: Surge::falling(limit),
899    }))
900}
901
902/// Feeds a value to a surge detector.
903///
904/// # Returns
905///
906/// `true` if the change since the previous reading went past the limit in the
907/// watched direction, having written its size, as a positive number, to `out_delta`;
908/// `false` otherwise, on the first reading, or if `surge` is null.
909///
910/// # Safety
911///
912/// `surge` must be a live handle from a surge constructor, or null, and
913/// `out_delta` must point to a writable `float` or be null.
914#[no_mangle]
915pub unsafe extern "C" fn pamoja_surge_update(
916    surge: *mut PamojaSurge,
917    value: f32,
918    out_delta: *mut f32,
919) -> bool {
920    let Some(surge) = surge.as_mut() else {
921        return false;
922    };
923    write_some(surge.inner.update(value), out_delta)
924}
925
926/// Creates a calibration applying `raw * scale + offset`.
927///
928/// # Returns
929///
930/// A handle the caller must release with [`pamoja_calibration_free`].
931///
932/// # Safety
933///
934/// The returned handle must be freed exactly once.
935#[no_mangle]
936pub unsafe extern "C" fn pamoja_calibration_linear(
937    scale: f32,
938    offset: f32,
939) -> *mut PamojaCalibration {
940    Box::into_raw(Box::new(PamojaCalibration {
941        inner: Calibration::linear(scale, offset),
942    }))
943}
944
945/// Creates a calibration fitted through two known reference points.
946///
947/// # Returns
948///
949/// A handle the caller must release with [`pamoja_calibration_free`].
950///
951/// # Safety
952///
953/// The returned handle must be freed exactly once.
954#[no_mangle]
955pub unsafe extern "C" fn pamoja_calibration_two_point(
956    raw_low: f32,
957    value_low: f32,
958    raw_high: f32,
959    value_high: f32,
960) -> *mut PamojaCalibration {
961    Box::into_raw(Box::new(PamojaCalibration {
962        inner: Calibration::two_point(raw_low, value_low, raw_high, value_high),
963    }))
964}
965
966/// Converts a raw reading into calibrated units.
967///
968/// # Returns
969///
970/// The calibrated value, or NaN if `calibration` is null.
971///
972/// # Safety
973///
974/// `calibration` must be a live handle from a calibration constructor, or null.
975#[no_mangle]
976pub unsafe extern "C" fn pamoja_calibration_apply(
977    calibration: *const PamojaCalibration,
978    raw: f32,
979) -> f32 {
980    match calibration.as_ref() {
981        Some(calibration) => calibration.inner.apply(raw),
982        None => f32::NAN,
983    }
984}
985
986/// Creates a circular geofence of `radius_m` around `center`.
987///
988/// # Returns
989///
990/// A handle the caller must release with [`pamoja_geofence_free`].
991///
992/// # Safety
993///
994/// The returned handle must be freed exactly once.
995#[no_mangle]
996pub unsafe extern "C" fn pamoja_geofence_new(
997    center: PamojaCoordinate,
998    radius_m: f64,
999) -> *mut PamojaGeofence {
1000    Box::into_raw(Box::new(PamojaGeofence {
1001        inner: Geofence::new(center.into(), radius_m),
1002    }))
1003}
1004
1005/// Feeds a fix to a geofence and reports where it sits, including a crossing.
1006///
1007/// # Returns
1008///
1009/// The boundary state for this fix, or [`PamojaBoundary::Outside`] if `geofence`
1010/// is null.
1011///
1012/// # Safety
1013///
1014/// `geofence` must be a live handle from [`pamoja_geofence_new`], or null.
1015#[no_mangle]
1016pub unsafe extern "C" fn pamoja_geofence_update(
1017    geofence: *mut PamojaGeofence,
1018    point: PamojaCoordinate,
1019) -> PamojaBoundary {
1020    match geofence.as_mut() {
1021        Some(geofence) => geofence.inner.update(point.into()).into(),
1022        None => PamojaBoundary::Outside,
1023    }
1024}
1025
1026/// Reports whether a fix lies inside a geofence, without recording a crossing.
1027///
1028/// # Returns
1029///
1030/// `true` if the fix is inside, or `false` if it is outside or `geofence` is null.
1031///
1032/// # Safety
1033///
1034/// `geofence` must be a live handle from [`pamoja_geofence_new`], or null.
1035#[no_mangle]
1036pub unsafe extern "C" fn pamoja_geofence_contains(
1037    geofence: *const PamojaGeofence,
1038    point: PamojaCoordinate,
1039) -> bool {
1040    match geofence.as_ref() {
1041        Some(geofence) => geofence.inner.contains(point.into()),
1042        None => false,
1043    }
1044}
1045
1046/// Returns the great-circle distance between two coordinates, in meters.
1047#[no_mangle]
1048pub extern "C" fn pamoja_coordinate_distance_to(
1049    from: PamojaCoordinate,
1050    to: PamojaCoordinate,
1051) -> f64 {
1052    Coordinate::from(from).distance_to(to.into())
1053}
1054
1055/// Returns the initial bearing from one coordinate to another, in degrees.
1056#[no_mangle]
1057pub extern "C" fn pamoja_coordinate_bearing_to(
1058    from: PamojaCoordinate,
1059    to: PamojaCoordinate,
1060) -> f64 {
1061    Coordinate::from(from).bearing_to(to.into())
1062}
1063
1064/// Holds a reading at `center` while it stays within `width` either side, so small
1065/// wiggle around a setpoint does not act.
1066///
1067/// # Returns
1068///
1069/// `center` while `value` is within `width` of it, and otherwise `value` unchanged.
1070#[no_mangle]
1071pub extern "C" fn pamoja_kit_deadband(value: f32, center: f32, width: f32) -> f32 {
1072    deadband(value, center, width)
1073}
1074
1075/// An opaque handle to a complementary filter.
1076pub struct PamojaComplementary {
1077    inner: Complementary,
1078}
1079
1080/// Creates a complementary filter, which fuses a drifting rate with a noisy absolute
1081/// reading.
1082///
1083/// # Arguments
1084///
1085/// * `alpha` - the weight on the integrated rate, held to 0 to 1; near 1 trusts the rate and
1086///   corrects slowly. One that is not a number is taken as 0, following the absolute
1087///   reading.
1088/// * `initial` - the starting estimate.
1089///
1090/// # Returns
1091///
1092/// A handle the caller must release with [`pamoja_complementary_free`].
1093///
1094/// # Safety
1095///
1096/// The returned handle must be freed exactly once.
1097#[no_mangle]
1098pub unsafe extern "C" fn pamoja_complementary_new(
1099    alpha: f32,
1100    initial: f32,
1101) -> *mut PamojaComplementary {
1102    Box::into_raw(Box::new(PamojaComplementary {
1103        inner: Complementary::new(alpha, initial),
1104    }))
1105}
1106
1107/// Fuses a rate and an absolute reading over a time step.
1108///
1109/// If the rate, the absolute reading, or the time step is not a finite number, the update
1110/// is ignored.
1111///
1112/// # Returns
1113///
1114/// The fused estimate, or NaN if `filter` is null.
1115///
1116/// # Safety
1117///
1118/// `filter` must be a live handle from [`pamoja_complementary_new`], or null.
1119#[no_mangle]
1120pub unsafe extern "C" fn pamoja_complementary_update(
1121    filter: *mut PamojaComplementary,
1122    rate: f32,
1123    absolute: f32,
1124    dt: f32,
1125) -> f32 {
1126    match filter.as_mut() {
1127        Some(filter) => filter.inner.update(rate, absolute, dt),
1128        None => f32::NAN,
1129    }
1130}
1131
1132/// Reads a complementary filter's estimate.
1133///
1134/// # Returns
1135///
1136/// The estimate, or NaN if `filter` is null.
1137///
1138/// # Safety
1139///
1140/// `filter` must be a live handle from [`pamoja_complementary_new`], or null.
1141#[no_mangle]
1142pub unsafe extern "C" fn pamoja_complementary_estimate(filter: *const PamojaComplementary) -> f32 {
1143    match filter.as_ref() {
1144        Some(filter) => filter.inner.estimate(),
1145        None => f32::NAN,
1146    }
1147}
1148
1149/// Releases a complementary filter handle.
1150///
1151/// Passing null is a no-op.
1152///
1153/// # Safety
1154///
1155/// `filter` must be a handle from [`pamoja_complementary_new`] that has not already been
1156/// freed, or null. After this call it must not be used again.
1157#[no_mangle]
1158pub unsafe extern "C" fn pamoja_complementary_free(filter: *mut PamojaComplementary) {
1159    if !filter.is_null() {
1160        drop(Box::from_raw(filter));
1161    }
1162}
1163
1164/// Roll and pitch, in degrees.
1165#[repr(C)]
1166#[derive(Clone, Copy, Debug, PartialEq)]
1167pub struct PamojaTilt {
1168    /// Rotation about the forward axis, in degrees, from -180 to 180.
1169    pub roll: f64,
1170    /// Rotation about the right axis, in degrees, from -90 to 90.
1171    pub pitch: f64,
1172}
1173
1174/// Computes roll and pitch from a three-axis accelerometer at rest.
1175///
1176/// # Arguments
1177///
1178/// * `ax` - acceleration along the forward axis, in any unit.
1179/// * `ay` - acceleration along the right axis, in the same unit.
1180/// * `az` - acceleration along the up axis, in the same unit.
1181///
1182/// # Returns
1183///
1184/// The tilt in degrees.
1185#[no_mangle]
1186pub extern "C" fn pamoja_imu_tilt_from_accel(ax: f64, ay: f64, az: f64) -> PamojaTilt {
1187    let tilt = imu::tilt_from_accel(ax, ay, az);
1188    PamojaTilt {
1189        roll: tilt.roll,
1190        pitch: tilt.pitch,
1191    }
1192}
1193
1194/// Computes the dew point from the air temperature and relative humidity.
1195///
1196/// # Arguments
1197///
1198/// * `celsius` - the air temperature, in degrees Celsius.
1199/// * `humidity_percent` - the relative humidity, in percent; one at or below 0 is taken as
1200///   a tiny positive value.
1201///
1202/// # Returns
1203///
1204/// The dew point, in degrees Celsius.
1205#[no_mangle]
1206pub extern "C" fn pamoja_weather_dew_point(celsius: f64, humidity_percent: f64) -> f64 {
1207    weather::dew_point(celsius, humidity_percent)
1208}
1209
1210/// Converts degrees Celsius to degrees Fahrenheit.
1211#[no_mangle]
1212pub extern "C" fn pamoja_units_celsius_to_fahrenheit(celsius: f32) -> f32 {
1213    units::celsius_to_fahrenheit(celsius)
1214}
1215
1216/// Converts degrees Fahrenheit to degrees Celsius.
1217#[no_mangle]
1218pub extern "C" fn pamoja_units_fahrenheit_to_celsius(fahrenheit: f32) -> f32 {
1219    units::fahrenheit_to_celsius(fahrenheit)
1220}
1221
1222/// Converts degrees Celsius to kelvin.
1223#[no_mangle]
1224pub extern "C" fn pamoja_units_celsius_to_kelvin(celsius: f32) -> f32 {
1225    units::celsius_to_kelvin(celsius)
1226}
1227
1228/// Converts kelvin to degrees Celsius.
1229#[no_mangle]
1230pub extern "C" fn pamoja_units_kelvin_to_celsius(kelvin: f32) -> f32 {
1231    units::kelvin_to_celsius(kelvin)
1232}
1233
1234/// Converts pascals to hectopascals.
1235#[no_mangle]
1236pub extern "C" fn pamoja_units_pascals_to_hectopascals(pascals: f32) -> f32 {
1237    units::pascals_to_hectopascals(pascals)
1238}
1239
1240/// Converts hectopascals to pascals.
1241#[no_mangle]
1242pub extern "C" fn pamoja_units_hectopascals_to_pascals(hectopascals: f32) -> f32 {
1243    units::hectopascals_to_pascals(hectopascals)
1244}
1245
1246/// Converts pascals to kilopascals.
1247#[no_mangle]
1248pub extern "C" fn pamoja_units_pascals_to_kilopascals(pascals: f32) -> f32 {
1249    units::pascals_to_kilopascals(pascals)
1250}
1251
1252/// Converts kilopascals to pascals.
1253#[no_mangle]
1254pub extern "C" fn pamoja_units_kilopascals_to_pascals(kilopascals: f32) -> f32 {
1255    units::kilopascals_to_pascals(kilopascals)
1256}
1257
1258/// Converts pascals to pounds per square inch.
1259#[no_mangle]
1260pub extern "C" fn pamoja_units_pascals_to_psi(pascals: f32) -> f32 {
1261    units::pascals_to_psi(pascals)
1262}
1263
1264/// Converts pounds per square inch to pascals.
1265#[no_mangle]
1266pub extern "C" fn pamoja_units_psi_to_pascals(psi: f32) -> f32 {
1267    units::psi_to_pascals(psi)
1268}
1269
1270/// Converts a ratio from 0 to 1 to a percentage.
1271#[no_mangle]
1272pub extern "C" fn pamoja_units_ratio_to_percent(ratio: f32) -> f32 {
1273    units::ratio_to_percent(ratio)
1274}
1275
1276/// Converts a percentage to a ratio from 0 to 1.
1277#[no_mangle]
1278pub extern "C" fn pamoja_units_percent_to_ratio(percent: f32) -> f32 {
1279    units::percent_to_ratio(percent)
1280}
1281
1282/// Writes an optional value through an out-pointer, reporting whether it was set.
1283///
1284/// # Safety
1285///
1286/// `out` must point to a writable `T`, or be null.
1287unsafe fn write_some<T>(value: Option<T>, out: *mut T) -> bool {
1288    match value {
1289        Some(value) => {
1290            if !out.is_null() {
1291                ptr::write(out, value);
1292            }
1293            true
1294        }
1295        None => false,
1296    }
1297}
1298
1299/// The most readings a windowed helper keeps, and the number it keeps unless told
1300/// fewer.
1301///
1302/// The Rust helpers are generic over their capacity, which cannot cross a C ABI,
1303/// so the ones here are built with room for this many; each has a `_with_capacity`
1304/// constructor that keeps fewer.
1305pub const PAMOJA_WINDOW_CAPACITY: usize = 32;
1306
1307/// Checks a capacity asked of a windowed helper: from `least`, the fewest readings the
1308/// helper can answer from, up to [`PAMOJA_WINDOW_CAPACITY`].
1309fn fits(capacity: usize, least: usize) -> bool {
1310    (least..=PAMOJA_WINDOW_CAPACITY).contains(&capacity)
1311}
1312
1313/// An opaque handle to a rolling window of readings.
1314pub struct PamojaWindow {
1315    inner: Window<PAMOJA_WINDOW_CAPACITY>,
1316}
1317
1318/// An opaque handle to a median filter.
1319pub struct PamojaMedian {
1320    inner: Median<PAMOJA_WINDOW_CAPACITY>,
1321}
1322
1323/// An opaque handle to a trend estimator.
1324pub struct PamojaTrend {
1325    inner: Trend<PAMOJA_WINDOW_CAPACITY>,
1326}
1327
1328/// An opaque handle to an anomaly detector.
1329pub struct PamojaAnomaly {
1330    inner: Anomaly<PAMOJA_WINDOW_CAPACITY>,
1331}
1332
1333/// Creates an empty rolling window of [`PAMOJA_WINDOW_CAPACITY`] readings.
1334///
1335/// # Returns
1336///
1337/// A handle the caller must release with [`pamoja_window_free`].
1338///
1339/// # Safety
1340///
1341/// The returned handle must be freed exactly once.
1342#[no_mangle]
1343pub unsafe extern "C" fn pamoja_window_new() -> *mut PamojaWindow {
1344    Box::into_raw(Box::new(PamojaWindow {
1345        inner: Window::new(),
1346    }))
1347}
1348
1349/// Creates an empty rolling window that keeps up to `capacity` readings.
1350///
1351/// # Returns
1352///
1353/// A handle the caller must release with [`pamoja_window_free`], or null if
1354/// `capacity` is not from 1 to [`PAMOJA_WINDOW_CAPACITY`].
1355///
1356/// # Safety
1357///
1358/// A returned handle must be freed exactly once.
1359#[no_mangle]
1360pub unsafe extern "C" fn pamoja_window_with_capacity(capacity: usize) -> *mut PamojaWindow {
1361    if !fits(capacity, 1) {
1362        return ptr::null_mut();
1363    }
1364    Box::into_raw(Box::new(PamojaWindow {
1365        inner: Window::with_capacity(capacity),
1366    }))
1367}
1368
1369/// Adds a reading, dropping the oldest once the window is full.
1370///
1371/// Passing null is a no-op.
1372///
1373/// # Safety
1374///
1375/// `window` must be a live handle from a window constructor, or null.
1376#[no_mangle]
1377pub unsafe extern "C" fn pamoja_window_push(window: *mut PamojaWindow, reading: f32) {
1378    if let Some(window) = window.as_mut() {
1379        window.inner.push(reading);
1380    }
1381}
1382
1383/// Returns how many readings a window holds.
1384///
1385/// # Returns
1386///
1387/// The count, or 0 if `window` is null.
1388///
1389/// # Safety
1390///
1391/// `window` must be a live handle from a window constructor, or null.
1392#[no_mangle]
1393pub unsafe extern "C" fn pamoja_window_len(window: *const PamojaWindow) -> usize {
1394    match window.as_ref() {
1395        Some(window) => window.inner.len(),
1396        None => 0,
1397    }
1398}
1399
1400/// Returns how many readings a window holds before it starts dropping.
1401///
1402/// # Returns
1403///
1404/// The capacity, or 0 if `window` is null.
1405///
1406/// # Safety
1407///
1408/// `window` must be a live handle from a window constructor, or null.
1409#[no_mangle]
1410pub unsafe extern "C" fn pamoja_window_capacity(window: *const PamojaWindow) -> usize {
1411    match window.as_ref() {
1412        Some(window) => window.inner.capacity(),
1413        None => 0,
1414    }
1415}
1416
1417/// Reports whether a window holds as many readings as it keeps.
1418///
1419/// # Returns
1420///
1421/// `true` once the window is full, or `false` before that or if `window` is null.
1422///
1423/// # Safety
1424///
1425/// `window` must be a live handle from a window constructor, or null.
1426#[no_mangle]
1427pub unsafe extern "C" fn pamoja_window_is_full(window: *const PamojaWindow) -> bool {
1428    match window.as_ref() {
1429        Some(window) => window.inner.is_full(),
1430        None => false,
1431    }
1432}
1433
1434/// Reads the most recent reading in a window.
1435///
1436/// # Returns
1437///
1438/// `true` when the window holds a reading, with it written to `out_value`.
1439///
1440/// # Safety
1441///
1442/// `window` must be a live handle or null, and `out_value` must point to a
1443/// writable `float`.
1444#[no_mangle]
1445pub unsafe extern "C" fn pamoja_window_latest(
1446    window: *const PamojaWindow,
1447    out_value: *mut f32,
1448) -> bool {
1449    maybe(
1450        window.as_ref().and_then(|window| window.inner.latest()),
1451        out_value,
1452    )
1453}
1454
1455/// Reads the oldest reading a window still holds.
1456///
1457/// # Returns
1458///
1459/// `true` when the window holds a reading, with it written to `out_value`.
1460///
1461/// # Safety
1462///
1463/// `window` must be a live handle or null, and `out_value` must point to a
1464/// writable `float`.
1465#[no_mangle]
1466pub unsafe extern "C" fn pamoja_window_oldest(
1467    window: *const PamojaWindow,
1468    out_value: *mut f32,
1469) -> bool {
1470    maybe(
1471        window.as_ref().and_then(|window| window.inner.oldest()),
1472        out_value,
1473    )
1474}
1475
1476/// Reads the mean of a window's readings.
1477///
1478/// # Returns
1479///
1480/// `true` when the window holds a reading, with the mean written to `out_value`.
1481///
1482/// # Safety
1483///
1484/// `window` must be a live handle or null, and `out_value` must point to a
1485/// writable `float`.
1486#[no_mangle]
1487pub unsafe extern "C" fn pamoja_window_mean(
1488    window: *const PamojaWindow,
1489    out_value: *mut f32,
1490) -> bool {
1491    maybe(
1492        window.as_ref().and_then(|window| window.inner.mean()),
1493        out_value,
1494    )
1495}
1496
1497/// Reads the smallest reading in a window.
1498///
1499/// # Returns
1500///
1501/// `true` when the window holds a reading, with the value written to `out_value`.
1502///
1503/// # Safety
1504///
1505/// `window` must be a live handle or null, and `out_value` must point to a
1506/// writable `float`.
1507#[no_mangle]
1508pub unsafe extern "C" fn pamoja_window_min(
1509    window: *const PamojaWindow,
1510    out_value: *mut f32,
1511) -> bool {
1512    maybe(
1513        window.as_ref().and_then(|window| window.inner.min()),
1514        out_value,
1515    )
1516}
1517
1518/// Reads the largest reading in a window.
1519///
1520/// # Returns
1521///
1522/// `true` when the window holds a reading, with the value written to `out_value`.
1523///
1524/// # Safety
1525///
1526/// `window` must be a live handle or null, and `out_value` must point to a
1527/// writable `float`.
1528#[no_mangle]
1529pub unsafe extern "C" fn pamoja_window_max(
1530    window: *const PamojaWindow,
1531    out_value: *mut f32,
1532) -> bool {
1533    maybe(
1534        window.as_ref().and_then(|window| window.inner.max()),
1535        out_value,
1536    )
1537}
1538
1539/// Reads the spread between a window's smallest and largest readings.
1540///
1541/// # Returns
1542///
1543/// `true` when the window holds a reading, with the range written to `out_value`.
1544///
1545/// # Safety
1546///
1547/// `window` must be a live handle or null, and `out_value` must point to a
1548/// writable `float`.
1549#[no_mangle]
1550pub unsafe extern "C" fn pamoja_window_range(
1551    window: *const PamojaWindow,
1552    out_value: *mut f32,
1553) -> bool {
1554    maybe(
1555        window.as_ref().and_then(|window| window.inner.range()),
1556        out_value,
1557    )
1558}
1559
1560/// Reads the population variance of a window's readings.
1561///
1562/// # Returns
1563///
1564/// `true` when the window holds a reading, with the variance written to
1565/// `out_value`; one reading has a variance of 0.
1566///
1567/// # Safety
1568///
1569/// `window` must be a live handle or null, and `out_value` must point to a
1570/// writable `float`.
1571#[no_mangle]
1572pub unsafe extern "C" fn pamoja_window_variance(
1573    window: *const PamojaWindow,
1574    out_value: *mut f32,
1575) -> bool {
1576    maybe(
1577        window.as_ref().and_then(|window| window.inner.variance()),
1578        out_value,
1579    )
1580}
1581
1582/// Releases a rolling window handle.
1583///
1584/// Passing null is a no-op.
1585///
1586/// # Safety
1587///
1588/// `window` must be a handle from a window constructor that has not already been
1589/// freed, or null. After this call it must not be used again.
1590#[no_mangle]
1591pub unsafe extern "C" fn pamoja_window_free(window: *mut PamojaWindow) {
1592    if !window.is_null() {
1593        drop(Box::from_raw(window));
1594    }
1595}
1596
1597/// Creates an empty median filter over [`PAMOJA_WINDOW_CAPACITY`] readings.
1598///
1599/// A median filter is what rejects a single wild reading, where an average would
1600/// let it pull the answer.
1601///
1602/// # Returns
1603///
1604/// A handle the caller must release with [`pamoja_median_free`].
1605///
1606/// # Safety
1607///
1608/// The returned handle must be freed exactly once.
1609#[no_mangle]
1610pub unsafe extern "C" fn pamoja_median_new() -> *mut PamojaMedian {
1611    Box::into_raw(Box::new(PamojaMedian {
1612        inner: Median::new(),
1613    }))
1614}
1615
1616/// Creates an empty median filter over up to `capacity` readings.
1617///
1618/// A small odd window, such as 5, follows a real change in a few readings; a window
1619/// of 32 follows it 16 readings late.
1620///
1621/// # Returns
1622///
1623/// A handle the caller must release with [`pamoja_median_free`], or null if
1624/// `capacity` is not from 1 to [`PAMOJA_WINDOW_CAPACITY`].
1625///
1626/// # Safety
1627///
1628/// A returned handle must be freed exactly once.
1629#[no_mangle]
1630pub unsafe extern "C" fn pamoja_median_with_capacity(capacity: usize) -> *mut PamojaMedian {
1631    if !fits(capacity, 1) {
1632        return ptr::null_mut();
1633    }
1634    Box::into_raw(Box::new(PamojaMedian {
1635        inner: Median::with_capacity(capacity),
1636    }))
1637}
1638
1639/// Returns how many readings a median filter keeps.
1640///
1641/// # Returns
1642///
1643/// The capacity, or 0 if `median` is null.
1644///
1645/// # Safety
1646///
1647/// `median` must be a live handle from a median constructor, or null.
1648#[no_mangle]
1649pub unsafe extern "C" fn pamoja_median_capacity(median: *const PamojaMedian) -> usize {
1650    match median.as_ref() {
1651        Some(median) => median.inner.capacity(),
1652        None => 0,
1653    }
1654}
1655
1656/// Folds a reading in and returns the median of the window.
1657///
1658/// # Returns
1659///
1660/// The median, or NaN if `median` is null.
1661///
1662/// # Safety
1663///
1664/// `median` must be a live handle from a median constructor, or null.
1665#[no_mangle]
1666pub unsafe extern "C" fn pamoja_median_update(median: *mut PamojaMedian, reading: f32) -> f32 {
1667    match median.as_mut() {
1668        Some(median) => median.inner.update(reading),
1669        None => f32::NAN,
1670    }
1671}
1672
1673/// Reads the current median without folding in a reading.
1674///
1675/// # Returns
1676///
1677/// `true` when the filter holds a reading, with the median written to
1678/// `out_value`.
1679///
1680/// # Safety
1681///
1682/// `median` must be a live handle or null, and `out_value` must point to a
1683/// writable `float`.
1684#[no_mangle]
1685pub unsafe extern "C" fn pamoja_median_value(
1686    median: *const PamojaMedian,
1687    out_value: *mut f32,
1688) -> bool {
1689    maybe(
1690        median.as_ref().and_then(|median| median.inner.median()),
1691        out_value,
1692    )
1693}
1694
1695/// Releases a median filter handle.
1696///
1697/// Passing null is a no-op.
1698///
1699/// # Safety
1700///
1701/// `median` must be a handle from a median constructor that has not already been
1702/// freed, or null. After this call it must not be used again.
1703#[no_mangle]
1704pub unsafe extern "C" fn pamoja_median_free(median: *mut PamojaMedian) {
1705    if !median.is_null() {
1706        drop(Box::from_raw(median));
1707    }
1708}
1709
1710/// Creates an empty trend estimator over [`PAMOJA_WINDOW_CAPACITY`] readings.
1711///
1712/// # Returns
1713///
1714/// A handle the caller must release with [`pamoja_trend_free`].
1715///
1716/// # Safety
1717///
1718/// The returned handle must be freed exactly once.
1719#[no_mangle]
1720pub unsafe extern "C" fn pamoja_trend_new() -> *mut PamojaTrend {
1721    Box::into_raw(Box::new(PamojaTrend {
1722        inner: Trend::new(),
1723    }))
1724}
1725
1726/// Creates an empty trend estimator over up to `capacity` readings.
1727///
1728/// # Returns
1729///
1730/// A handle the caller must release with [`pamoja_trend_free`], or null if
1731/// `capacity` is not from 2 to [`PAMOJA_WINDOW_CAPACITY`], since a line needs two
1732/// readings.
1733///
1734/// # Safety
1735///
1736/// A returned handle must be freed exactly once.
1737#[no_mangle]
1738pub unsafe extern "C" fn pamoja_trend_with_capacity(capacity: usize) -> *mut PamojaTrend {
1739    if !fits(capacity, 2) {
1740        return ptr::null_mut();
1741    }
1742    Box::into_raw(Box::new(PamojaTrend {
1743        inner: Trend::with_capacity(capacity),
1744    }))
1745}
1746
1747/// Returns how many readings a trend estimator keeps.
1748///
1749/// # Returns
1750///
1751/// The capacity, or 0 if `trend` is null.
1752///
1753/// # Safety
1754///
1755/// `trend` must be a live handle from a trend constructor, or null.
1756#[no_mangle]
1757pub unsafe extern "C" fn pamoja_trend_capacity(trend: *const PamojaTrend) -> usize {
1758    match trend.as_ref() {
1759        Some(trend) => trend.inner.capacity(),
1760        None => 0,
1761    }
1762}
1763
1764/// Adds a reading to a trend estimator.
1765///
1766/// Passing null is a no-op.
1767///
1768/// # Safety
1769///
1770/// `trend` must be a live handle from a trend constructor, or null.
1771#[no_mangle]
1772pub unsafe extern "C" fn pamoja_trend_push(trend: *mut PamojaTrend, reading: f32) {
1773    if let Some(trend) = trend.as_mut() {
1774        trend.inner.push(reading);
1775    }
1776}
1777
1778/// Reads the slope a trend estimator has fitted, in units per reading.
1779///
1780/// # Returns
1781///
1782/// `true` once it holds two readings, with the slope written to `out_value`. A
1783/// positive slope is a rising signal.
1784///
1785/// # Safety
1786///
1787/// `trend` must be a live handle or null, and `out_value` must point to a
1788/// writable `float`.
1789#[no_mangle]
1790pub unsafe extern "C" fn pamoja_trend_slope(
1791    trend: *const PamojaTrend,
1792    out_value: *mut f32,
1793) -> bool {
1794    maybe(
1795        trend.as_ref().and_then(|trend| trend.inner.slope()),
1796        out_value,
1797    )
1798}
1799
1800/// Releases a trend estimator handle.
1801///
1802/// Passing null is a no-op.
1803///
1804/// # Safety
1805///
1806/// `trend` must be a handle from a trend constructor that has not already been
1807/// freed, or null. After this call it must not be used again.
1808#[no_mangle]
1809pub unsafe extern "C" fn pamoja_trend_free(trend: *mut PamojaTrend) {
1810    if !trend.is_null() {
1811        drop(Box::from_raw(trend));
1812    }
1813}
1814
1815/// Creates an anomaly detector that flags a reading `sigmas` deviations out.
1816///
1817/// # Returns
1818///
1819/// A handle the caller must release with [`pamoja_anomaly_free`].
1820///
1821/// # Safety
1822///
1823/// The returned handle must be freed exactly once.
1824#[no_mangle]
1825pub unsafe extern "C" fn pamoja_anomaly_new(sigmas: f32) -> *mut PamojaAnomaly {
1826    Box::into_raw(Box::new(PamojaAnomaly {
1827        inner: Anomaly::new(sigmas),
1828    }))
1829}
1830
1831/// Creates an anomaly detector whose baseline keeps up to `capacity` readings.
1832///
1833/// # Returns
1834///
1835/// A handle the caller must release with [`pamoja_anomaly_free`], or null if
1836/// `capacity` is not from 2 to [`PAMOJA_WINDOW_CAPACITY`], since a spread needs two
1837/// readings.
1838///
1839/// # Safety
1840///
1841/// A returned handle must be freed exactly once.
1842#[no_mangle]
1843pub unsafe extern "C" fn pamoja_anomaly_with_capacity(
1844    sigmas: f32,
1845    capacity: usize,
1846) -> *mut PamojaAnomaly {
1847    if !fits(capacity, 2) {
1848        return ptr::null_mut();
1849    }
1850    Box::into_raw(Box::new(PamojaAnomaly {
1851        inner: Anomaly::with_capacity(sigmas, capacity),
1852    }))
1853}
1854
1855/// Returns how many readings an anomaly detector's baseline keeps.
1856///
1857/// # Returns
1858///
1859/// The capacity, or 0 if `anomaly` is null.
1860///
1861/// # Safety
1862///
1863/// `anomaly` must be a live handle from an anomaly constructor, or null.
1864#[no_mangle]
1865pub unsafe extern "C" fn pamoja_anomaly_capacity(anomaly: *const PamojaAnomaly) -> usize {
1866    match anomaly.as_ref() {
1867        Some(anomaly) => anomaly.inner.capacity(),
1868        None => 0,
1869    }
1870}
1871
1872/// Folds a reading in and reports whether it stands out from the readings before it.
1873///
1874/// # Returns
1875///
1876/// `true` when the reading is further from the mean than the configured number
1877/// of deviations, or is not a finite number. `false` otherwise, before two readings
1878/// are held, or if `anomaly` is null.
1879///
1880/// # Safety
1881///
1882/// `anomaly` must be a live handle from an anomaly constructor, or null.
1883#[no_mangle]
1884pub unsafe extern "C" fn pamoja_anomaly_check(anomaly: *mut PamojaAnomaly, reading: f32) -> bool {
1885    match anomaly.as_mut() {
1886        Some(anomaly) => anomaly.inner.check(reading),
1887        None => false,
1888    }
1889}
1890
1891/// Releases an anomaly detector handle.
1892///
1893/// Passing null is a no-op.
1894///
1895/// # Safety
1896///
1897/// `anomaly` must be a handle from an anomaly constructor that has not already
1898/// been freed, or null. After this call it must not be used again.
1899#[no_mangle]
1900pub unsafe extern "C" fn pamoja_anomaly_free(anomaly: *mut PamojaAnomaly) {
1901    if !anomaly.is_null() {
1902        drop(Box::from_raw(anomaly));
1903    }
1904}
1905
1906/// Writes an optional value through an out-pointer, reporting whether there was one.
1907///
1908/// # Safety
1909///
1910/// `out_value` must be null or point to a writable `float`.
1911unsafe fn maybe(value: Option<f32>, out_value: *mut f32) -> bool {
1912    match (value, out_value.as_mut()) {
1913        (Some(value), Some(slot)) => {
1914            *slot = value;
1915            true
1916        }
1917        _ => false,
1918    }
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923    use super::*;
1924
1925    #[test]
1926    fn a_smoother_converges_toward_the_signal() {
1927        // Safety: the handle is live for the whole test and freed once at the end.
1928        unsafe {
1929            let smoother = pamoja_smoother_new(0.5);
1930            assert_eq!(pamoja_smoother_update(smoother, 10.0), 10.0);
1931            let second = pamoja_smoother_update(smoother, 20.0);
1932            assert!(second > 10.0 && second < 20.0);
1933
1934            let mut value = 0.0f32;
1935            assert!(pamoja_smoother_value(smoother, &mut value));
1936            assert_eq!(value, second);
1937
1938            pamoja_smoother_reset(smoother);
1939            assert!(!pamoja_smoother_value(smoother, &mut value));
1940            pamoja_smoother_free(smoother);
1941        }
1942    }
1943
1944    #[test]
1945    fn a_cooling_thermostat_switches_on_above_its_setpoint() {
1946        // Safety: the handle is live for the whole test and freed once at the end.
1947        unsafe {
1948            let thermostat = pamoja_thermostat_cooling(8.0, 1.0);
1949            assert!(!pamoja_thermostat_update(thermostat, 7.0));
1950            assert!(pamoja_thermostat_update(thermostat, 9.5));
1951            assert!(pamoja_thermostat_is_on(thermostat));
1952            pamoja_thermostat_free(thermostat);
1953
1954            let trigger = pamoja_trigger_below(30.0, 5.0);
1955            assert_eq!(pamoja_trigger_update(trigger, 42.0), PamojaEdge::None);
1956            assert_eq!(pamoja_trigger_update(trigger, 28.0), PamojaEdge::Set);
1957            assert!(pamoja_trigger_is_set(trigger));
1958            assert_eq!(pamoja_trigger_update(trigger, 33.0), PamojaEdge::None);
1959            assert_eq!(pamoja_trigger_update(trigger, 36.0), PamojaEdge::Cleared);
1960            assert!(!pamoja_trigger_is_set(trigger));
1961            assert_eq!(
1962                pamoja_trigger_update(ptr::null_mut(), 1.0),
1963                PamojaEdge::None
1964            );
1965            pamoja_trigger_free(trigger);
1966        }
1967    }
1968
1969    #[test]
1970    fn a_depletion_estimate_appears_once_a_fall_is_measured() {
1971        // Safety: the handle is live for the whole test and freed once at the end.
1972        unsafe {
1973            let depletion = pamoja_depletion_new(10.0);
1974            let mut samples = 0u32;
1975            // The first reading establishes no rate, so there is nothing to report.
1976            assert!(!pamoja_depletion_update(depletion, 100.0, &mut samples));
1977            assert!(pamoja_depletion_update(depletion, 90.0, &mut samples));
1978            assert!(samples > 0);
1979            pamoja_depletion_free(depletion);
1980        }
1981    }
1982
1983    #[test]
1984    fn a_geofence_reports_the_single_crossing_fix() {
1985        let center = PamojaCoordinate {
1986            latitude: -1.2921,
1987            longitude: 36.8219,
1988        };
1989        // Safety: the handle is live for the whole test and freed once at the end.
1990        unsafe {
1991            let fence = pamoja_geofence_new(center, 50.0);
1992            assert_eq!(
1993                pamoja_geofence_update(fence, center),
1994                PamojaBoundary::Inside
1995            );
1996            let away = PamojaCoordinate {
1997                latitude: -1.2930,
1998                longitude: 36.8219,
1999            };
2000            assert_eq!(pamoja_geofence_update(fence, away), PamojaBoundary::Exited);
2001            assert_eq!(pamoja_geofence_update(fence, away), PamojaBoundary::Outside);
2002            assert!(!pamoja_geofence_contains(fence, away));
2003            pamoja_geofence_free(fence);
2004        }
2005    }
2006
2007    #[test]
2008    fn a_two_point_calibration_maps_its_reference_points() {
2009        // Safety: the handle is live for the whole test and freed once at the end.
2010        unsafe {
2011            let calibration = pamoja_calibration_two_point(0.0, 0.0, 1024.0, 100.0);
2012            assert!((pamoja_calibration_apply(calibration, 512.0) - 50.0).abs() < 0.01);
2013            pamoja_calibration_free(calibration);
2014        }
2015    }
2016
2017    #[test]
2018    fn a_deadband_holds_the_center_inside_the_band() {
2019        assert_eq!(pamoja_kit_deadband(0.2, 0.0, 0.5), 0.0);
2020        assert!(pamoja_kit_deadband(1.0, 0.0, 0.5) > 0.0);
2021    }
2022
2023    #[test]
2024    fn calls_on_null_handles_are_rejected_without_dereferencing() {
2025        // Safety: every entry point tolerates a null handle.
2026        unsafe {
2027            assert!(pamoja_smoother_update(ptr::null_mut(), 1.0).is_nan());
2028            assert!(!pamoja_thermostat_update(ptr::null_mut(), 1.0));
2029            assert!(!pamoja_depletion_update(
2030                ptr::null_mut(),
2031                1.0,
2032                ptr::null_mut()
2033            ));
2034            assert!(pamoja_calibration_apply(ptr::null(), 1.0).is_nan());
2035            assert_eq!(
2036                pamoja_geofence_update(
2037                    ptr::null_mut(),
2038                    PamojaCoordinate {
2039                        latitude: 0.0,
2040                        longitude: 0.0,
2041                    }
2042                ),
2043                PamojaBoundary::Outside
2044            );
2045            // Freeing null is a documented no-op.
2046            pamoja_smoother_free(ptr::null_mut());
2047        }
2048    }
2049
2050    #[test]
2051    fn a_surge_reports_the_step_that_crosses_its_limit() {
2052        // Safety: the handle is live for the whole test and freed once at the end.
2053        unsafe {
2054            let surge = pamoja_surge_rising(5.0);
2055            let mut delta = 0.0f32;
2056            assert!(!pamoja_surge_update(surge, 10.0, &mut delta));
2057            assert!(!pamoja_surge_update(surge, 12.0, &mut delta));
2058            assert!(pamoja_surge_update(surge, 20.0, &mut delta));
2059            assert!(delta >= 5.0);
2060            pamoja_surge_free(surge);
2061        }
2062    }
2063
2064    #[test]
2065    fn a_window_reports_its_spread_once_it_holds_a_reading() {
2066        // Safety: the handle is live and the out-pointer is writable.
2067        unsafe {
2068            let window = pamoja_window_new();
2069            let mut value = 0.0f32;
2070            assert!(!pamoja_window_mean(window, &mut value), "empty has no mean");
2071
2072            for reading in [10.0f32, 20.0, 30.0] {
2073                pamoja_window_push(window, reading);
2074            }
2075            assert_eq!(pamoja_window_len(window), 3);
2076            assert_eq!(pamoja_window_capacity(window), PAMOJA_WINDOW_CAPACITY);
2077            assert!(pamoja_window_mean(window, &mut value));
2078            assert!((value - 20.0).abs() < 1e-6);
2079            assert!(pamoja_window_min(window, &mut value) && (value - 10.0).abs() < 1e-6);
2080            assert!(pamoja_window_max(window, &mut value) && (value - 30.0).abs() < 1e-6);
2081            assert!(pamoja_window_range(window, &mut value) && (value - 20.0).abs() < 1e-6);
2082            assert!(pamoja_window_variance(window, &mut value));
2083            pamoja_window_free(window);
2084        }
2085    }
2086
2087    #[test]
2088    fn a_window_drops_its_oldest_reading_once_full() {
2089        // Safety: the handle is live and the out-pointer is writable.
2090        unsafe {
2091            let window = pamoja_window_new();
2092            for index in 0..PAMOJA_WINDOW_CAPACITY + 8 {
2093                pamoja_window_push(window, index as f32);
2094            }
2095            assert_eq!(pamoja_window_len(window), PAMOJA_WINDOW_CAPACITY);
2096            let mut value = 0.0f32;
2097            assert!(pamoja_window_min(window, &mut value));
2098            assert!((value - 8.0).abs() < 1e-6, "the first eight fell out");
2099            pamoja_window_free(window);
2100        }
2101    }
2102
2103    #[test]
2104    fn a_median_rejects_the_single_wild_reading_an_average_would_follow() {
2105        // Safety: the handle is live.
2106        unsafe {
2107            let median = pamoja_median_new();
2108            for reading in [20.0f32, 21.0, 20.5] {
2109                pamoja_median_update(median, reading);
2110            }
2111            let spike = pamoja_median_update(median, 900.0);
2112            assert!(spike < 30.0, "the spike does not carry the median: {spike}");
2113            pamoja_median_free(median);
2114        }
2115    }
2116
2117    #[test]
2118    fn a_trend_reports_a_rising_signal_as_a_positive_slope() {
2119        // Safety: the handle is live and the out-pointer is writable.
2120        unsafe {
2121            let trend = pamoja_trend_new();
2122            let mut slope = 0.0f32;
2123            assert!(
2124                !pamoja_trend_slope(trend, &mut slope),
2125                "one point is no line"
2126            );
2127
2128            for reading in [1.0f32, 2.0, 3.0, 4.0] {
2129                pamoja_trend_push(trend, reading);
2130            }
2131            assert!(pamoja_trend_slope(trend, &mut slope));
2132            assert!((slope - 1.0).abs() < 1e-5, "got {slope}");
2133            pamoja_trend_free(trend);
2134        }
2135    }
2136
2137    #[test]
2138    fn an_anomaly_detector_flags_the_reading_that_stands_out() {
2139        // Safety: the handle is live.
2140        unsafe {
2141            let anomaly = pamoja_anomaly_new(3.0);
2142            for _ in 0..8 {
2143                assert!(!pamoja_anomaly_check(anomaly, 20.0));
2144            }
2145            assert!(pamoja_anomaly_check(anomaly, 900.0), "a step that far out");
2146            pamoja_anomaly_free(anomaly);
2147        }
2148    }
2149
2150    #[test]
2151    fn a_windowed_helper_keeps_the_capacity_it_is_given_and_refuses_one_it_cannot() {
2152        // Safety: each handle is checked for null before use and freed once.
2153        unsafe {
2154            assert!(pamoja_window_with_capacity(0).is_null());
2155            assert!(pamoja_window_with_capacity(PAMOJA_WINDOW_CAPACITY + 1).is_null());
2156            assert!(pamoja_median_with_capacity(0).is_null());
2157            assert!(
2158                pamoja_trend_with_capacity(1).is_null(),
2159                "a line needs two readings"
2160            );
2161            assert!(
2162                pamoja_anomaly_with_capacity(3.0, 1).is_null(),
2163                "and so does a spread"
2164            );
2165
2166            let window = pamoja_window_with_capacity(2);
2167            assert!(!window.is_null());
2168            let mut value = 0.0f32;
2169            assert!(
2170                !pamoja_window_latest(window, &mut value),
2171                "empty has no latest"
2172            );
2173            for reading in [1.0f32, 2.0, 3.0] {
2174                pamoja_window_push(window, reading);
2175            }
2176            assert!(pamoja_window_is_full(window));
2177            assert_eq!(pamoja_window_capacity(window), 2);
2178            assert!(pamoja_window_oldest(window, &mut value) && value == 2.0);
2179            assert!(pamoja_window_latest(window, &mut value) && value == 3.0);
2180            pamoja_window_push(window, f32::NAN);
2181            assert!(pamoja_window_mean(window, &mut value) && value == 2.5);
2182            pamoja_window_free(window);
2183
2184            let median = pamoja_median_with_capacity(3);
2185            assert_eq!(pamoja_median_capacity(median), 3);
2186            pamoja_median_free(median);
2187            let trend = pamoja_trend_with_capacity(4);
2188            assert_eq!(pamoja_trend_capacity(trend), 4);
2189            pamoja_trend_free(trend);
2190            let anomaly = pamoja_anomaly_with_capacity(3.0, 8);
2191            assert_eq!(pamoja_anomaly_capacity(anomaly), 8);
2192            assert!(pamoja_anomaly_check(anomaly, f32::NAN), "a NaN is flagged");
2193            pamoja_anomaly_free(anomaly);
2194        }
2195    }
2196
2197    #[test]
2198    fn the_unit_tilt_and_dew_point_helpers_convert() {
2199        assert_eq!(pamoja_units_celsius_to_fahrenheit(100.0), 212.0);
2200        assert_eq!(pamoja_units_fahrenheit_to_celsius(32.0), 0.0);
2201        assert!((pamoja_units_celsius_to_kelvin(0.0) - 273.15).abs() < 1e-4);
2202        assert_eq!(pamoja_units_pascals_to_hectopascals(101_325.0), 1013.25);
2203        assert!((pamoja_units_psi_to_pascals(1.0) - 6894.757).abs() < 1e-2);
2204        assert_eq!(pamoja_units_ratio_to_percent(0.25), 25.0);
2205        let rolled = pamoja_imu_tilt_from_accel(0.0, 1.0, 1.0);
2206        assert!((rolled.roll - 45.0).abs() < 1e-9 && rolled.pitch.abs() < 1e-9);
2207        assert!((pamoja_weather_dew_point(15.0, 100.0) - 15.0).abs() < 1e-6);
2208        assert!((pamoja_weather_dew_point(20.0, 50.0) - 9.3).abs() < 0.2);
2209    }
2210
2211    #[test]
2212    fn a_complementary_filter_ignores_a_reading_it_cannot_use() {
2213        // Safety: the handle is live for the whole test and freed once at the end.
2214        unsafe {
2215            let tilt = pamoja_complementary_new(0.98, 0.0);
2216            let settled = pamoja_complementary_update(tilt, 10.0, 1.0, 0.1);
2217            assert!((settled - 1.0).abs() < 0.05);
2218            assert_eq!(
2219                pamoja_complementary_update(tilt, f32::NAN, 1.0, 0.1),
2220                settled
2221            );
2222            assert_eq!(pamoja_complementary_estimate(tilt), settled);
2223            pamoja_complementary_free(tilt);
2224            assert!(pamoja_complementary_estimate(ptr::null()).is_nan());
2225        }
2226    }
2227
2228    #[test]
2229    fn a_trigger_reports_the_line_it_was_given() {
2230        // Safety: the handle is live for the whole test and freed once at the end.
2231        unsafe {
2232            let trigger = pamoja_trigger_above(80.0, -2.0);
2233            assert_eq!(pamoja_trigger_threshold(trigger), 80.0);
2234            assert_eq!(pamoja_trigger_hysteresis(trigger), 2.0, "as a magnitude");
2235            assert!(pamoja_trigger_watches_above(trigger));
2236            assert_eq!(pamoja_trigger_update(trigger, f32::NAN), PamojaEdge::None);
2237            pamoja_trigger_free(trigger);
2238            assert!(pamoja_trigger_threshold(ptr::null()).is_nan());
2239            assert!(!pamoja_trigger_watches_above(ptr::null()));
2240        }
2241    }
2242
2243    #[test]
2244    fn calls_on_null_windowed_handles_are_rejected_without_dereferencing() {
2245        let mut value = 0.0f32;
2246        // Safety: passing null is explicitly handled.
2247        unsafe {
2248            assert_eq!(pamoja_window_len(ptr::null()), 0);
2249            assert!(!pamoja_window_mean(ptr::null(), &mut value));
2250            assert!(pamoja_median_update(ptr::null_mut(), 1.0).is_nan());
2251            assert!(!pamoja_trend_slope(ptr::null(), &mut value));
2252            assert!(!pamoja_anomaly_check(ptr::null_mut(), 1.0));
2253            pamoja_window_free(ptr::null_mut());
2254            pamoja_median_free(ptr::null_mut());
2255            pamoja_trend_free(ptr::null_mut());
2256            pamoja_anomaly_free(ptr::null_mut());
2257        }
2258    }
2259}