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 constructor returns null only when allocation is refused,
11//! and whose methods document what they return for a null handle, following
12//! `pamoja_mqtt_client_is_connected`.
13//! Helpers that answer "maybe" return a `bool` and write the value through an
14//! out-parameter.
15
16use std::ptr;
17
18use pamoja_kit::{
19    deadband, Anomaly, Boundary, Calibration, Coordinate, Debounce, Depletion, Geofence, Kalman,
20    Median, Pid, Ramp, Smoother, Surge, Thermostat, Trend, Window,
21};
22
23/// A latitude and longitude in degrees.
24#[repr(C)]
25#[derive(Clone, Copy)]
26pub struct PamojaCoordinate {
27    /// Degrees north of the equator, negative for south.
28    pub latitude: f64,
29    /// Degrees east of the prime meridian, negative for west.
30    pub longitude: f64,
31}
32
33impl From<PamojaCoordinate> for Coordinate {
34    fn from(value: PamojaCoordinate) -> Self {
35        Coordinate::new(value.latitude, value.longitude)
36    }
37}
38
39/// Where a fix sits relative to a geofence, including the moment it crosses.
40#[repr(C)]
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
42pub enum PamojaBoundary {
43    /// The fix is inside the fence and was inside before, or is the first fix inside.
44    Inside = 0,
45    /// The fix is outside the fence and was outside before, or is the first fix outside.
46    Outside = 1,
47    /// The fix just crossed from inside to outside: the moment to raise a breach alert.
48    Exited = 2,
49    /// The fix just crossed from outside back inside.
50    Entered = 3,
51}
52
53impl From<Boundary> for PamojaBoundary {
54    fn from(value: Boundary) -> Self {
55        match value {
56            Boundary::Inside => Self::Inside,
57            Boundary::Outside => Self::Outside,
58            Boundary::Exited => Self::Exited,
59            Boundary::Entered => Self::Entered,
60        }
61    }
62}
63
64// Each handle below is written out rather than generated by a macro: cbindgen
65// parses this source without expanding macros, so a macro-defined type would be
66// referenced by the header without ever being declared in it.
67
68/// An opaque handle to an exponential smoother.
69pub struct PamojaSmoother {
70    inner: Smoother,
71}
72
73/// Releases a smoother handle.
74///
75/// Passing null is a no-op.
76///
77/// # Safety
78///
79/// `smoother` must be a handle from [`pamoja_smoother_new`] that has not already been
80/// freed, or null. After this call it must not be used again.
81#[no_mangle]
82pub unsafe extern "C" fn pamoja_smoother_free(smoother: *mut PamojaSmoother) {
83    if !smoother.is_null() {
84        drop(Box::from_raw(smoother));
85    }
86}
87
88/// An opaque handle to a PID controller.
89pub struct PamojaPid {
90    inner: Pid,
91}
92
93/// Releases a PID controller handle.
94///
95/// Passing null is a no-op.
96///
97/// # Safety
98///
99/// `pid` must be a handle from [`pamoja_pid_new`] that has not already been
100/// freed, or null. After this call it must not be used again.
101#[no_mangle]
102pub unsafe extern "C" fn pamoja_pid_free(pid: *mut PamojaPid) {
103    if !pid.is_null() {
104        drop(Box::from_raw(pid));
105    }
106}
107
108/// An opaque handle to an on/off controller with hysteresis.
109pub struct PamojaThermostat {
110    inner: Thermostat,
111}
112
113/// Releases a thermostat handle.
114///
115/// Passing null is a no-op.
116///
117/// # Safety
118///
119/// `thermostat` must be a handle from [`pamoja_thermostat_cooling`] that has not already been
120/// freed, or null. After this call it must not be used again.
121#[no_mangle]
122pub unsafe extern "C" fn pamoja_thermostat_free(thermostat: *mut PamojaThermostat) {
123    if !thermostat.is_null() {
124        drop(Box::from_raw(thermostat));
125    }
126}
127
128/// An opaque handle to a depletion estimator.
129pub struct PamojaDepletion {
130    inner: Depletion,
131}
132
133/// Releases a depletion estimator handle.
134///
135/// Passing null is a no-op.
136///
137/// # Safety
138///
139/// `depletion` must be a handle from [`pamoja_depletion_new`] that has not already been
140/// freed, or null. After this call it must not be used again.
141#[no_mangle]
142pub unsafe extern "C" fn pamoja_depletion_free(depletion: *mut PamojaDepletion) {
143    if !depletion.is_null() {
144        drop(Box::from_raw(depletion));
145    }
146}
147
148/// An opaque handle to a one-dimensional Kalman filter.
149pub struct PamojaKalman {
150    inner: Kalman,
151}
152
153/// Releases a Kalman filter handle.
154///
155/// Passing null is a no-op.
156///
157/// # Safety
158///
159/// `kalman` must be a handle from [`pamoja_kalman_new`] that has not already been
160/// freed, or null. After this call it must not be used again.
161#[no_mangle]
162pub unsafe extern "C" fn pamoja_kalman_free(kalman: *mut PamojaKalman) {
163    if !kalman.is_null() {
164        drop(Box::from_raw(kalman));
165    }
166}
167
168/// An opaque handle to a boolean debouncer.
169pub struct PamojaDebounce {
170    inner: Debounce,
171}
172
173/// Releases a debouncer handle.
174///
175/// Passing null is a no-op.
176///
177/// # Safety
178///
179/// `debounce` must be a handle from [`pamoja_debounce_new`] that has not already been
180/// freed, or null. After this call it must not be used again.
181#[no_mangle]
182pub unsafe extern "C" fn pamoja_debounce_free(debounce: *mut PamojaDebounce) {
183    if !debounce.is_null() {
184        drop(Box::from_raw(debounce));
185    }
186}
187
188/// An opaque handle to a rate limiter.
189pub struct PamojaRamp {
190    inner: Ramp,
191}
192
193/// Releases a rate limiter handle.
194///
195/// Passing null is a no-op.
196///
197/// # Safety
198///
199/// `ramp` must be a handle from [`pamoja_ramp_new`] that has not already been
200/// freed, or null. After this call it must not be used again.
201#[no_mangle]
202pub unsafe extern "C" fn pamoja_ramp_free(ramp: *mut PamojaRamp) {
203    if !ramp.is_null() {
204        drop(Box::from_raw(ramp));
205    }
206}
207
208/// An opaque handle to a step-change detector.
209pub struct PamojaSurge {
210    inner: Surge,
211}
212
213/// Releases a surge detector handle.
214///
215/// Passing null is a no-op.
216///
217/// # Safety
218///
219/// `surge` must be a handle from [`pamoja_surge_rising`] that has not already been
220/// freed, or null. After this call it must not be used again.
221#[no_mangle]
222pub unsafe extern "C" fn pamoja_surge_free(surge: *mut PamojaSurge) {
223    if !surge.is_null() {
224        drop(Box::from_raw(surge));
225    }
226}
227
228/// An opaque handle to a raw-to-units calibration.
229pub struct PamojaCalibration {
230    inner: Calibration,
231}
232
233/// Releases a calibration handle.
234///
235/// Passing null is a no-op.
236///
237/// # Safety
238///
239/// `calibration` must be a handle from [`pamoja_calibration_linear`] that has not already been
240/// freed, or null. After this call it must not be used again.
241#[no_mangle]
242pub unsafe extern "C" fn pamoja_calibration_free(calibration: *mut PamojaCalibration) {
243    if !calibration.is_null() {
244        drop(Box::from_raw(calibration));
245    }
246}
247
248/// An opaque handle to a circular geofence.
249pub struct PamojaGeofence {
250    inner: Geofence,
251}
252
253/// Releases a geofence handle.
254///
255/// Passing null is a no-op.
256///
257/// # Safety
258///
259/// `geofence` must be a handle from [`pamoja_geofence_new`] that has not already been
260/// freed, or null. After this call it must not be used again.
261#[no_mangle]
262pub unsafe extern "C" fn pamoja_geofence_free(geofence: *mut PamojaGeofence) {
263    if !geofence.is_null() {
264        drop(Box::from_raw(geofence));
265    }
266}
267
268/// Creates an exponential smoother.
269///
270/// # Returns
271///
272/// A handle the caller must release with [`pamoja_smoother_free`].
273///
274/// # Safety
275///
276/// The returned handle must be freed exactly once.
277#[no_mangle]
278pub unsafe extern "C" fn pamoja_smoother_new(weight: f32) -> *mut PamojaSmoother {
279    Box::into_raw(Box::new(PamojaSmoother {
280        inner: Smoother::new(weight),
281    }))
282}
283
284/// Folds a sample into a smoother and returns the smoothed value.
285///
286/// # Returns
287///
288/// The smoothed value, or NaN if `smoother` is null.
289///
290/// # Safety
291///
292/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null.
293#[no_mangle]
294pub unsafe extern "C" fn pamoja_smoother_update(smoother: *mut PamojaSmoother, sample: f32) -> f32 {
295    match smoother.as_mut() {
296        Some(smoother) => smoother.inner.update(sample),
297        None => f32::NAN,
298    }
299}
300
301/// Reads a smoother's current value without folding in a sample.
302///
303/// # Returns
304///
305/// `true` if a value is available, having written it to `out_value`; `false` if
306/// no sample has been seen yet or `smoother` is null.
307///
308/// # Safety
309///
310/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null, and
311/// `out_value` must point to a writable `float` or be null.
312#[no_mangle]
313pub unsafe extern "C" fn pamoja_smoother_value(
314    smoother: *const PamojaSmoother,
315    out_value: *mut f32,
316) -> bool {
317    let Some(smoother) = smoother.as_ref() else {
318        return false;
319    };
320    write_some(smoother.inner.value(), out_value)
321}
322
323/// Clears a smoother back to its initial state.
324///
325/// # Safety
326///
327/// `smoother` must be a live handle from [`pamoja_smoother_new`], or null.
328#[no_mangle]
329pub unsafe extern "C" fn pamoja_smoother_reset(smoother: *mut PamojaSmoother) {
330    if let Some(smoother) = smoother.as_mut() {
331        smoother.inner.reset();
332    }
333}
334
335/// Creates a PID controller with the given gains and no output limits.
336///
337/// # Returns
338///
339/// A handle the caller must release with [`pamoja_pid_free`].
340///
341/// # Safety
342///
343/// The returned handle must be freed exactly once.
344#[no_mangle]
345pub unsafe extern "C" fn pamoja_pid_new(kp: f32, ki: f32, kd: f32) -> *mut PamojaPid {
346    Box::into_raw(Box::new(PamojaPid {
347        inner: Pid::new(kp, ki, kd),
348    }))
349}
350
351/// Creates a PID controller whose output is clamped to `[min, max]`.
352///
353/// # Returns
354///
355/// A handle the caller must release with [`pamoja_pid_free`].
356///
357/// # Safety
358///
359/// The returned handle must be freed exactly once.
360#[no_mangle]
361pub unsafe extern "C" fn pamoja_pid_new_with_limits(
362    kp: f32,
363    ki: f32,
364    kd: f32,
365    min: f32,
366    max: f32,
367) -> *mut PamojaPid {
368    Box::into_raw(Box::new(PamojaPid {
369        inner: Pid::new(kp, ki, kd).with_limits(min, max),
370    }))
371}
372
373/// Advances a PID controller by one step.
374///
375/// # Returns
376///
377/// The control output, or NaN if `pid` is null.
378///
379/// # Safety
380///
381/// `pid` must be a live handle from a PID constructor, or null.
382#[no_mangle]
383pub unsafe extern "C" fn pamoja_pid_update(
384    pid: *mut PamojaPid,
385    setpoint: f32,
386    measurement: f32,
387    dt: f32,
388) -> f32 {
389    match pid.as_mut() {
390        Some(pid) => pid.inner.update(setpoint, measurement, dt),
391        None => f32::NAN,
392    }
393}
394
395/// Clears a PID controller's accumulated integral and last error.
396///
397/// # Safety
398///
399/// `pid` must be a live handle from a PID constructor, or null.
400#[no_mangle]
401pub unsafe extern "C" fn pamoja_pid_reset(pid: *mut PamojaPid) {
402    if let Some(pid) = pid.as_mut() {
403        pid.inner.reset();
404    }
405}
406
407/// Creates a cooling thermostat, which switches on when the reading rises.
408///
409/// # Returns
410///
411/// A handle the caller must release with [`pamoja_thermostat_free`].
412///
413/// # Safety
414///
415/// The returned handle must be freed exactly once.
416#[no_mangle]
417pub unsafe extern "C" fn pamoja_thermostat_cooling(
418    setpoint: f32,
419    hysteresis: f32,
420) -> *mut PamojaThermostat {
421    Box::into_raw(Box::new(PamojaThermostat {
422        inner: Thermostat::cooling(setpoint, hysteresis),
423    }))
424}
425
426/// Creates a heating thermostat, which switches on when the reading falls.
427///
428/// # Returns
429///
430/// A handle the caller must release with [`pamoja_thermostat_free`].
431///
432/// # Safety
433///
434/// The returned handle must be freed exactly once.
435#[no_mangle]
436pub unsafe extern "C" fn pamoja_thermostat_heating(
437    setpoint: f32,
438    hysteresis: f32,
439) -> *mut PamojaThermostat {
440    Box::into_raw(Box::new(PamojaThermostat {
441        inner: Thermostat::heating(setpoint, hysteresis),
442    }))
443}
444
445/// Feeds a reading to a thermostat and returns whether the load should be on.
446///
447/// # Returns
448///
449/// `true` while the load should run, or `false` if `thermostat` is null.
450///
451/// # Safety
452///
453/// `thermostat` must be a live handle from a thermostat constructor, or null.
454#[no_mangle]
455pub unsafe extern "C" fn pamoja_thermostat_update(
456    thermostat: *mut PamojaThermostat,
457    reading: f32,
458) -> bool {
459    match thermostat.as_mut() {
460        Some(thermostat) => thermostat.inner.update(reading),
461        None => false,
462    }
463}
464
465/// Reports a thermostat's current output without feeding it a reading.
466///
467/// # Returns
468///
469/// `true` while the load should run, or `false` if `thermostat` is null.
470///
471/// # Safety
472///
473/// `thermostat` must be a live handle from a thermostat constructor, or null.
474#[no_mangle]
475pub unsafe extern "C" fn pamoja_thermostat_is_on(thermostat: *const PamojaThermostat) -> bool {
476    match thermostat.as_ref() {
477        Some(thermostat) => thermostat.inner.is_on(),
478        None => false,
479    }
480}
481
482/// Creates a depletion estimator that warns as a level approaches `threshold`.
483///
484/// # Returns
485///
486/// A handle the caller must release with [`pamoja_depletion_free`].
487///
488/// # Safety
489///
490/// The returned handle must be freed exactly once.
491#[no_mangle]
492pub unsafe extern "C" fn pamoja_depletion_new(threshold: f32) -> *mut PamojaDepletion {
493    Box::into_raw(Box::new(PamojaDepletion {
494        inner: Depletion::new(threshold),
495    }))
496}
497
498/// Records a level and estimates how many samples remain before the threshold.
499///
500/// # Returns
501///
502/// `true` if an estimate is available, having written it to `out_samples`;
503/// `false` if the level is steady or rising, if no rate is known yet, or if
504/// `depletion` is null.
505///
506/// # Safety
507///
508/// `depletion` must be a live handle from [`pamoja_depletion_new`], or null, and
509/// `out_samples` must point to a writable `uint32_t` or be null.
510#[no_mangle]
511pub unsafe extern "C" fn pamoja_depletion_update(
512    depletion: *mut PamojaDepletion,
513    level: f32,
514    out_samples: *mut u32,
515) -> bool {
516    let Some(depletion) = depletion.as_mut() else {
517        return false;
518    };
519    write_some(depletion.inner.update(level), out_samples)
520}
521
522/// Creates a one-dimensional Kalman filter.
523///
524/// # Returns
525///
526/// A handle the caller must release with [`pamoja_kalman_free`].
527///
528/// # Safety
529///
530/// The returned handle must be freed exactly once.
531#[no_mangle]
532pub unsafe extern "C" fn pamoja_kalman_new(
533    process_noise: f32,
534    measurement_noise: f32,
535    initial: f32,
536) -> *mut PamojaKalman {
537    Box::into_raw(Box::new(PamojaKalman {
538        inner: Kalman::new(process_noise, measurement_noise, initial),
539    }))
540}
541
542/// Folds a reading into a Kalman filter and returns the new estimate.
543///
544/// # Returns
545///
546/// The updated estimate, or NaN if `kalman` is null.
547///
548/// # Safety
549///
550/// `kalman` must be a live handle from [`pamoja_kalman_new`], or null.
551#[no_mangle]
552pub unsafe extern "C" fn pamoja_kalman_update(kalman: *mut PamojaKalman, reading: f32) -> f32 {
553    match kalman.as_mut() {
554        Some(kalman) => kalman.inner.update(reading),
555        None => f32::NAN,
556    }
557}
558
559/// Reads a Kalman filter's current estimate without folding in a reading.
560///
561/// # Returns
562///
563/// The current estimate, or NaN if `kalman` is null.
564///
565/// # Safety
566///
567/// `kalman` must be a live handle from [`pamoja_kalman_new`], or null.
568#[no_mangle]
569pub unsafe extern "C" fn pamoja_kalman_estimate(kalman: *const PamojaKalman) -> f32 {
570    match kalman.as_ref() {
571        Some(kalman) => kalman.inner.estimate(),
572        None => f32::NAN,
573    }
574}
575
576/// Creates a debouncer that requires `samples` agreeing readings to change state.
577///
578/// # Returns
579///
580/// A handle the caller must release with [`pamoja_debounce_free`].
581///
582/// # Safety
583///
584/// The returned handle must be freed exactly once.
585#[no_mangle]
586pub unsafe extern "C" fn pamoja_debounce_new(samples: u16, initial: bool) -> *mut PamojaDebounce {
587    Box::into_raw(Box::new(PamojaDebounce {
588        inner: Debounce::new(samples, initial),
589    }))
590}
591
592/// Feeds a raw reading to a debouncer and returns the settled state.
593///
594/// # Returns
595///
596/// The debounced state, or `false` if `debounce` is null.
597///
598/// # Safety
599///
600/// `debounce` must be a live handle from [`pamoja_debounce_new`], or null.
601#[no_mangle]
602pub unsafe extern "C" fn pamoja_debounce_update(debounce: *mut PamojaDebounce, raw: bool) -> bool {
603    match debounce.as_mut() {
604        Some(debounce) => debounce.inner.update(raw),
605        None => false,
606    }
607}
608
609/// Reports a debouncer's settled state without feeding it a reading.
610///
611/// # Returns
612///
613/// The debounced state, or `false` if `debounce` is null.
614///
615/// # Safety
616///
617/// `debounce` must be a live handle from [`pamoja_debounce_new`], or null.
618#[no_mangle]
619pub unsafe extern "C" fn pamoja_debounce_state(debounce: *const PamojaDebounce) -> bool {
620    match debounce.as_ref() {
621        Some(debounce) => debounce.inner.state(),
622        None => false,
623    }
624}
625
626/// Creates a rate limiter starting at `start` and moving at most `max_step`.
627///
628/// # Returns
629///
630/// A handle the caller must release with [`pamoja_ramp_free`].
631///
632/// # Safety
633///
634/// The returned handle must be freed exactly once.
635#[no_mangle]
636pub unsafe extern "C" fn pamoja_ramp_new(start: f32, max_step: f32) -> *mut PamojaRamp {
637    Box::into_raw(Box::new(PamojaRamp {
638        inner: Ramp::new(start, max_step),
639    }))
640}
641
642/// Moves a ramp one step toward `target` and returns the new value.
643///
644/// # Returns
645///
646/// The rate-limited value, or NaN if `ramp` is null.
647///
648/// # Safety
649///
650/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
651#[no_mangle]
652pub unsafe extern "C" fn pamoja_ramp_update(ramp: *mut PamojaRamp, target: f32) -> f32 {
653    match ramp.as_mut() {
654        Some(ramp) => ramp.inner.update(target),
655        None => f32::NAN,
656    }
657}
658
659/// Reads a ramp's current value.
660///
661/// # Returns
662///
663/// The current value, or NaN if `ramp` is null.
664///
665/// # Safety
666///
667/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
668#[no_mangle]
669pub unsafe extern "C" fn pamoja_ramp_value(ramp: *const PamojaRamp) -> f32 {
670    match ramp.as_ref() {
671        Some(ramp) => ramp.inner.value(),
672        None => f32::NAN,
673    }
674}
675
676/// Forces a ramp to a value without rate limiting.
677///
678/// # Safety
679///
680/// `ramp` must be a live handle from [`pamoja_ramp_new`], or null.
681#[no_mangle]
682pub unsafe extern "C" fn pamoja_ramp_set(ramp: *mut PamojaRamp, value: f32) {
683    if let Some(ramp) = ramp.as_mut() {
684        ramp.inner.set(value);
685    }
686}
687
688/// Creates a detector for rises of at least `limit` between readings.
689///
690/// # Returns
691///
692/// A handle the caller must release with [`pamoja_surge_free`].
693///
694/// # Safety
695///
696/// The returned handle must be freed exactly once.
697#[no_mangle]
698pub unsafe extern "C" fn pamoja_surge_rising(limit: f32) -> *mut PamojaSurge {
699    Box::into_raw(Box::new(PamojaSurge {
700        inner: Surge::rising(limit),
701    }))
702}
703
704/// Creates a detector for falls of at least `limit` between readings.
705///
706/// # Returns
707///
708/// A handle the caller must release with [`pamoja_surge_free`].
709///
710/// # Safety
711///
712/// The returned handle must be freed exactly once.
713#[no_mangle]
714pub unsafe extern "C" fn pamoja_surge_falling(limit: f32) -> *mut PamojaSurge {
715    Box::into_raw(Box::new(PamojaSurge {
716        inner: Surge::falling(limit),
717    }))
718}
719
720/// Feeds a value to a surge detector.
721///
722/// # Returns
723///
724/// `true` if this reading completed a qualifying step, having written the size of
725/// the step to `out_delta`; `false` otherwise or if `surge` is null.
726///
727/// # Safety
728///
729/// `surge` must be a live handle from a surge constructor, or null, and
730/// `out_delta` must point to a writable `float` or be null.
731#[no_mangle]
732pub unsafe extern "C" fn pamoja_surge_update(
733    surge: *mut PamojaSurge,
734    value: f32,
735    out_delta: *mut f32,
736) -> bool {
737    let Some(surge) = surge.as_mut() else {
738        return false;
739    };
740    write_some(surge.inner.update(value), out_delta)
741}
742
743/// Creates a calibration applying `raw * scale + offset`.
744///
745/// # Returns
746///
747/// A handle the caller must release with [`pamoja_calibration_free`].
748///
749/// # Safety
750///
751/// The returned handle must be freed exactly once.
752#[no_mangle]
753pub unsafe extern "C" fn pamoja_calibration_linear(
754    scale: f32,
755    offset: f32,
756) -> *mut PamojaCalibration {
757    Box::into_raw(Box::new(PamojaCalibration {
758        inner: Calibration::linear(scale, offset),
759    }))
760}
761
762/// Creates a calibration fitted through two known reference points.
763///
764/// # Returns
765///
766/// A handle the caller must release with [`pamoja_calibration_free`].
767///
768/// # Safety
769///
770/// The returned handle must be freed exactly once.
771#[no_mangle]
772pub unsafe extern "C" fn pamoja_calibration_two_point(
773    raw_low: f32,
774    value_low: f32,
775    raw_high: f32,
776    value_high: f32,
777) -> *mut PamojaCalibration {
778    Box::into_raw(Box::new(PamojaCalibration {
779        inner: Calibration::two_point(raw_low, value_low, raw_high, value_high),
780    }))
781}
782
783/// Converts a raw reading into calibrated units.
784///
785/// # Returns
786///
787/// The calibrated value, or NaN if `calibration` is null.
788///
789/// # Safety
790///
791/// `calibration` must be a live handle from a calibration constructor, or null.
792#[no_mangle]
793pub unsafe extern "C" fn pamoja_calibration_apply(
794    calibration: *const PamojaCalibration,
795    raw: f32,
796) -> f32 {
797    match calibration.as_ref() {
798        Some(calibration) => calibration.inner.apply(raw),
799        None => f32::NAN,
800    }
801}
802
803/// Creates a circular geofence of `radius_m` around `center`.
804///
805/// # Returns
806///
807/// A handle the caller must release with [`pamoja_geofence_free`].
808///
809/// # Safety
810///
811/// The returned handle must be freed exactly once.
812#[no_mangle]
813pub unsafe extern "C" fn pamoja_geofence_new(
814    center: PamojaCoordinate,
815    radius_m: f64,
816) -> *mut PamojaGeofence {
817    Box::into_raw(Box::new(PamojaGeofence {
818        inner: Geofence::new(center.into(), radius_m),
819    }))
820}
821
822/// Feeds a fix to a geofence and reports where it sits, including a crossing.
823///
824/// # Returns
825///
826/// The boundary state for this fix, or [`PamojaBoundary::Outside`] if `geofence`
827/// is null.
828///
829/// # Safety
830///
831/// `geofence` must be a live handle from [`pamoja_geofence_new`], or null.
832#[no_mangle]
833pub unsafe extern "C" fn pamoja_geofence_update(
834    geofence: *mut PamojaGeofence,
835    point: PamojaCoordinate,
836) -> PamojaBoundary {
837    match geofence.as_mut() {
838        Some(geofence) => geofence.inner.update(point.into()).into(),
839        None => PamojaBoundary::Outside,
840    }
841}
842
843/// Reports whether a fix lies inside a geofence, without recording a crossing.
844///
845/// # Returns
846///
847/// `true` if the fix is inside, or `false` if it is outside or `geofence` is null.
848///
849/// # Safety
850///
851/// `geofence` must be a live handle from [`pamoja_geofence_new`], or null.
852#[no_mangle]
853pub unsafe extern "C" fn pamoja_geofence_contains(
854    geofence: *const PamojaGeofence,
855    point: PamojaCoordinate,
856) -> bool {
857    match geofence.as_ref() {
858        Some(geofence) => geofence.inner.contains(point.into()),
859        None => false,
860    }
861}
862
863/// Returns the great-circle distance between two coordinates, in metres.
864#[no_mangle]
865pub extern "C" fn pamoja_coordinate_distance_to(
866    from: PamojaCoordinate,
867    to: PamojaCoordinate,
868) -> f64 {
869    Coordinate::from(from).distance_to(to.into())
870}
871
872/// Returns the initial bearing from one coordinate to another, in degrees.
873#[no_mangle]
874pub extern "C" fn pamoja_coordinate_bearing_to(
875    from: PamojaCoordinate,
876    to: PamojaCoordinate,
877) -> f64 {
878    Coordinate::from(from).bearing_to(to.into())
879}
880
881/// Suppresses movement within `width` of `center`, so noise does not act.
882///
883/// # Returns
884///
885/// `center` while `value` is inside the band, and otherwise `value` shifted
886/// toward `center` by half the band width, so the output is continuous.
887#[no_mangle]
888pub extern "C" fn pamoja_kit_deadband(value: f32, center: f32, width: f32) -> f32 {
889    deadband(value, center, width)
890}
891
892/// Writes an optional value through an out-pointer, reporting whether it was set.
893///
894/// # Safety
895///
896/// `out` must point to a writable `T`, or be null.
897unsafe fn write_some<T>(value: Option<T>, out: *mut T) -> bool {
898    match value {
899        Some(value) => {
900            if !out.is_null() {
901                ptr::write(out, value);
902            }
903            true
904        }
905        None => false,
906    }
907}
908
909/// The number of readings a windowed helper keeps.
910///
911/// The Rust helpers are generic over their capacity, which cannot cross a C ABI,
912/// so the ones here are built at one documented size. The crate's own examples
913/// use three to eight readings, so this is headroom rather than a constraint; a
914/// caller who needs another size has the Rust crate.
915pub const PAMOJA_WINDOW_CAPACITY: usize = 32;
916
917/// An opaque handle to a rolling window of readings.
918pub struct PamojaWindow {
919    inner: Window<PAMOJA_WINDOW_CAPACITY>,
920}
921
922/// An opaque handle to a median filter.
923pub struct PamojaMedian {
924    inner: Median<PAMOJA_WINDOW_CAPACITY>,
925}
926
927/// An opaque handle to a trend estimator.
928pub struct PamojaTrend {
929    inner: Trend<PAMOJA_WINDOW_CAPACITY>,
930}
931
932/// An opaque handle to an anomaly detector.
933pub struct PamojaAnomaly {
934    inner: Anomaly<PAMOJA_WINDOW_CAPACITY>,
935}
936
937/// Creates an empty rolling window of [`PAMOJA_WINDOW_CAPACITY`] readings.
938///
939/// # Returns
940///
941/// A handle the caller must release with [`pamoja_window_free`].
942///
943/// # Safety
944///
945/// The returned handle must be freed exactly once.
946#[no_mangle]
947pub unsafe extern "C" fn pamoja_window_new() -> *mut PamojaWindow {
948    Box::into_raw(Box::new(PamojaWindow {
949        inner: Window::new(),
950    }))
951}
952
953/// Adds a reading, dropping the oldest once the window is full.
954///
955/// Passing null is a no-op.
956///
957/// # Safety
958///
959/// `window` must be a live handle from [`pamoja_window_new`], or null.
960#[no_mangle]
961pub unsafe extern "C" fn pamoja_window_push(window: *mut PamojaWindow, reading: f32) {
962    if let Some(window) = window.as_mut() {
963        window.inner.push(reading);
964    }
965}
966
967/// Returns how many readings a window holds.
968///
969/// # Returns
970///
971/// The count, or 0 if `window` is null.
972///
973/// # Safety
974///
975/// `window` must be a live handle from [`pamoja_window_new`], or null.
976#[no_mangle]
977pub unsafe extern "C" fn pamoja_window_len(window: *const PamojaWindow) -> usize {
978    match window.as_ref() {
979        Some(window) => window.inner.len(),
980        None => 0,
981    }
982}
983
984/// Returns how many readings a window holds before it starts dropping.
985///
986/// # Returns
987///
988/// The capacity, or 0 if `window` is null.
989///
990/// # Safety
991///
992/// `window` must be a live handle from [`pamoja_window_new`], or null.
993#[no_mangle]
994pub unsafe extern "C" fn pamoja_window_capacity(window: *const PamojaWindow) -> usize {
995    match window.as_ref() {
996        Some(window) => window.inner.capacity(),
997        None => 0,
998    }
999}
1000
1001/// Reads the mean of a window's readings.
1002///
1003/// # Returns
1004///
1005/// `true` when the window holds a reading, with the mean written to `out_value`.
1006///
1007/// # Safety
1008///
1009/// `window` must be a live handle or null, and `out_value` must point to a
1010/// writable `float`.
1011#[no_mangle]
1012pub unsafe extern "C" fn pamoja_window_mean(
1013    window: *const PamojaWindow,
1014    out_value: *mut f32,
1015) -> bool {
1016    maybe(
1017        window.as_ref().and_then(|window| window.inner.mean()),
1018        out_value,
1019    )
1020}
1021
1022/// Reads the smallest reading in a window.
1023///
1024/// # Returns
1025///
1026/// `true` when the window holds a reading, with the value written to `out_value`.
1027///
1028/// # Safety
1029///
1030/// `window` must be a live handle or null, and `out_value` must point to a
1031/// writable `float`.
1032#[no_mangle]
1033pub unsafe extern "C" fn pamoja_window_min(
1034    window: *const PamojaWindow,
1035    out_value: *mut f32,
1036) -> bool {
1037    maybe(
1038        window.as_ref().and_then(|window| window.inner.min()),
1039        out_value,
1040    )
1041}
1042
1043/// Reads the largest reading in a window.
1044///
1045/// # Returns
1046///
1047/// `true` when the window holds a reading, with the value written to `out_value`.
1048///
1049/// # Safety
1050///
1051/// `window` must be a live handle or null, and `out_value` must point to a
1052/// writable `float`.
1053#[no_mangle]
1054pub unsafe extern "C" fn pamoja_window_max(
1055    window: *const PamojaWindow,
1056    out_value: *mut f32,
1057) -> bool {
1058    maybe(
1059        window.as_ref().and_then(|window| window.inner.max()),
1060        out_value,
1061    )
1062}
1063
1064/// Reads the spread between a window's smallest and largest readings.
1065///
1066/// # Returns
1067///
1068/// `true` when the window holds a reading, with the range written to `out_value`.
1069///
1070/// # Safety
1071///
1072/// `window` must be a live handle or null, and `out_value` must point to a
1073/// writable `float`.
1074#[no_mangle]
1075pub unsafe extern "C" fn pamoja_window_range(
1076    window: *const PamojaWindow,
1077    out_value: *mut f32,
1078) -> bool {
1079    maybe(
1080        window.as_ref().and_then(|window| window.inner.range()),
1081        out_value,
1082    )
1083}
1084
1085/// Reads the variance of a window's readings.
1086///
1087/// # Returns
1088///
1089/// `true` when the window holds enough readings to have a variance, with it
1090/// written to `out_value`.
1091///
1092/// # Safety
1093///
1094/// `window` must be a live handle or null, and `out_value` must point to a
1095/// writable `float`.
1096#[no_mangle]
1097pub unsafe extern "C" fn pamoja_window_variance(
1098    window: *const PamojaWindow,
1099    out_value: *mut f32,
1100) -> bool {
1101    maybe(
1102        window.as_ref().and_then(|window| window.inner.variance()),
1103        out_value,
1104    )
1105}
1106
1107/// Releases a rolling window handle.
1108///
1109/// Passing null is a no-op.
1110///
1111/// # Safety
1112///
1113/// `window` must be a handle from [`pamoja_window_new`] that has not already been
1114/// freed, or null. After this call it must not be used again.
1115#[no_mangle]
1116pub unsafe extern "C" fn pamoja_window_free(window: *mut PamojaWindow) {
1117    if !window.is_null() {
1118        drop(Box::from_raw(window));
1119    }
1120}
1121
1122/// Creates an empty median filter over [`PAMOJA_WINDOW_CAPACITY`] readings.
1123///
1124/// A median filter is what rejects a single wild reading, where an average would
1125/// let it pull the answer.
1126///
1127/// # Returns
1128///
1129/// A handle the caller must release with [`pamoja_median_free`].
1130///
1131/// # Safety
1132///
1133/// The returned handle must be freed exactly once.
1134#[no_mangle]
1135pub unsafe extern "C" fn pamoja_median_new() -> *mut PamojaMedian {
1136    Box::into_raw(Box::new(PamojaMedian {
1137        inner: Median::new(),
1138    }))
1139}
1140
1141/// Folds a reading in and returns the median of the window.
1142///
1143/// # Returns
1144///
1145/// The median, or NaN if `median` is null.
1146///
1147/// # Safety
1148///
1149/// `median` must be a live handle from [`pamoja_median_new`], or null.
1150#[no_mangle]
1151pub unsafe extern "C" fn pamoja_median_update(median: *mut PamojaMedian, reading: f32) -> f32 {
1152    match median.as_mut() {
1153        Some(median) => median.inner.update(reading),
1154        None => f32::NAN,
1155    }
1156}
1157
1158/// Reads the current median without folding in a reading.
1159///
1160/// # Returns
1161///
1162/// `true` when the filter holds a reading, with the median written to
1163/// `out_value`.
1164///
1165/// # Safety
1166///
1167/// `median` must be a live handle or null, and `out_value` must point to a
1168/// writable `float`.
1169#[no_mangle]
1170pub unsafe extern "C" fn pamoja_median_value(
1171    median: *const PamojaMedian,
1172    out_value: *mut f32,
1173) -> bool {
1174    maybe(
1175        median.as_ref().and_then(|median| median.inner.median()),
1176        out_value,
1177    )
1178}
1179
1180/// Releases a median filter handle.
1181///
1182/// Passing null is a no-op.
1183///
1184/// # Safety
1185///
1186/// `median` must be a handle from [`pamoja_median_new`] that has not already been
1187/// freed, or null. After this call it must not be used again.
1188#[no_mangle]
1189pub unsafe extern "C" fn pamoja_median_free(median: *mut PamojaMedian) {
1190    if !median.is_null() {
1191        drop(Box::from_raw(median));
1192    }
1193}
1194
1195/// Creates an empty trend estimator over [`PAMOJA_WINDOW_CAPACITY`] readings.
1196///
1197/// # Returns
1198///
1199/// A handle the caller must release with [`pamoja_trend_free`].
1200///
1201/// # Safety
1202///
1203/// The returned handle must be freed exactly once.
1204#[no_mangle]
1205pub unsafe extern "C" fn pamoja_trend_new() -> *mut PamojaTrend {
1206    Box::into_raw(Box::new(PamojaTrend {
1207        inner: Trend::new(),
1208    }))
1209}
1210
1211/// Adds a reading to a trend estimator.
1212///
1213/// Passing null is a no-op.
1214///
1215/// # Safety
1216///
1217/// `trend` must be a live handle from [`pamoja_trend_new`], or null.
1218#[no_mangle]
1219pub unsafe extern "C" fn pamoja_trend_push(trend: *mut PamojaTrend, reading: f32) {
1220    if let Some(trend) = trend.as_mut() {
1221        trend.inner.push(reading);
1222    }
1223}
1224
1225/// Reads the slope a trend estimator has fitted, in units per reading.
1226///
1227/// # Returns
1228///
1229/// `true` when there are enough readings to fit a line, with the slope written to
1230/// `out_value`. A positive slope is a rising signal.
1231///
1232/// # Safety
1233///
1234/// `trend` must be a live handle or null, and `out_value` must point to a
1235/// writable `float`.
1236#[no_mangle]
1237pub unsafe extern "C" fn pamoja_trend_slope(
1238    trend: *const PamojaTrend,
1239    out_value: *mut f32,
1240) -> bool {
1241    maybe(
1242        trend.as_ref().and_then(|trend| trend.inner.slope()),
1243        out_value,
1244    )
1245}
1246
1247/// Releases a trend estimator handle.
1248///
1249/// Passing null is a no-op.
1250///
1251/// # Safety
1252///
1253/// `trend` must be a handle from [`pamoja_trend_new`] that has not already been
1254/// freed, or null. After this call it must not be used again.
1255#[no_mangle]
1256pub unsafe extern "C" fn pamoja_trend_free(trend: *mut PamojaTrend) {
1257    if !trend.is_null() {
1258        drop(Box::from_raw(trend));
1259    }
1260}
1261
1262/// Creates an anomaly detector that flags a reading `sigmas` deviations out.
1263///
1264/// # Returns
1265///
1266/// A handle the caller must release with [`pamoja_anomaly_free`].
1267///
1268/// # Safety
1269///
1270/// The returned handle must be freed exactly once.
1271#[no_mangle]
1272pub unsafe extern "C" fn pamoja_anomaly_new(sigmas: f32) -> *mut PamojaAnomaly {
1273    Box::into_raw(Box::new(PamojaAnomaly {
1274        inner: Anomaly::new(sigmas),
1275    }))
1276}
1277
1278/// Folds a reading in and reports whether it stands out from the window.
1279///
1280/// # Returns
1281///
1282/// `true` when the reading is further from the mean than the configured number
1283/// of deviations, or `false` if `anomaly` is null or the window is still filling.
1284///
1285/// # Safety
1286///
1287/// `anomaly` must be a live handle from [`pamoja_anomaly_new`], or null.
1288#[no_mangle]
1289pub unsafe extern "C" fn pamoja_anomaly_check(anomaly: *mut PamojaAnomaly, reading: f32) -> bool {
1290    match anomaly.as_mut() {
1291        Some(anomaly) => anomaly.inner.check(reading),
1292        None => false,
1293    }
1294}
1295
1296/// Releases an anomaly detector handle.
1297///
1298/// Passing null is a no-op.
1299///
1300/// # Safety
1301///
1302/// `anomaly` must be a handle from [`pamoja_anomaly_new`] that has not already
1303/// been freed, or null. After this call it must not be used again.
1304#[no_mangle]
1305pub unsafe extern "C" fn pamoja_anomaly_free(anomaly: *mut PamojaAnomaly) {
1306    if !anomaly.is_null() {
1307        drop(Box::from_raw(anomaly));
1308    }
1309}
1310
1311/// Writes an optional value through an out-pointer, reporting whether there was one.
1312///
1313/// # Safety
1314///
1315/// `out_value` must be null or point to a writable `float`.
1316unsafe fn maybe(value: Option<f32>, out_value: *mut f32) -> bool {
1317    match (value, out_value.as_mut()) {
1318        (Some(value), Some(slot)) => {
1319            *slot = value;
1320            true
1321        }
1322        _ => false,
1323    }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    #[test]
1331    fn a_smoother_converges_toward_the_signal() {
1332        // Safety: the handle is live for the whole test and freed once at the end.
1333        unsafe {
1334            let smoother = pamoja_smoother_new(0.5);
1335            assert_eq!(pamoja_smoother_update(smoother, 10.0), 10.0);
1336            let second = pamoja_smoother_update(smoother, 20.0);
1337            assert!(second > 10.0 && second < 20.0);
1338
1339            let mut value = 0.0f32;
1340            assert!(pamoja_smoother_value(smoother, &mut value));
1341            assert_eq!(value, second);
1342
1343            pamoja_smoother_reset(smoother);
1344            assert!(!pamoja_smoother_value(smoother, &mut value));
1345            pamoja_smoother_free(smoother);
1346        }
1347    }
1348
1349    #[test]
1350    fn a_cooling_thermostat_switches_on_above_its_setpoint() {
1351        // Safety: the handle is live for the whole test and freed once at the end.
1352        unsafe {
1353            let thermostat = pamoja_thermostat_cooling(8.0, 1.0);
1354            assert!(!pamoja_thermostat_update(thermostat, 7.0));
1355            assert!(pamoja_thermostat_update(thermostat, 9.5));
1356            assert!(pamoja_thermostat_is_on(thermostat));
1357            pamoja_thermostat_free(thermostat);
1358        }
1359    }
1360
1361    #[test]
1362    fn a_depletion_estimate_appears_once_a_fall_is_measured() {
1363        // Safety: the handle is live for the whole test and freed once at the end.
1364        unsafe {
1365            let depletion = pamoja_depletion_new(10.0);
1366            let mut samples = 0u32;
1367            // The first reading establishes no rate, so there is nothing to report.
1368            assert!(!pamoja_depletion_update(depletion, 100.0, &mut samples));
1369            assert!(pamoja_depletion_update(depletion, 90.0, &mut samples));
1370            assert!(samples > 0);
1371            pamoja_depletion_free(depletion);
1372        }
1373    }
1374
1375    #[test]
1376    fn a_geofence_reports_the_single_crossing_fix() {
1377        let centre = PamojaCoordinate {
1378            latitude: -1.2921,
1379            longitude: 36.8219,
1380        };
1381        // Safety: the handle is live for the whole test and freed once at the end.
1382        unsafe {
1383            let fence = pamoja_geofence_new(centre, 50.0);
1384            assert_eq!(
1385                pamoja_geofence_update(fence, centre),
1386                PamojaBoundary::Inside
1387            );
1388            let away = PamojaCoordinate {
1389                latitude: -1.2930,
1390                longitude: 36.8219,
1391            };
1392            assert_eq!(pamoja_geofence_update(fence, away), PamojaBoundary::Exited);
1393            assert_eq!(pamoja_geofence_update(fence, away), PamojaBoundary::Outside);
1394            assert!(!pamoja_geofence_contains(fence, away));
1395            pamoja_geofence_free(fence);
1396        }
1397    }
1398
1399    #[test]
1400    fn a_two_point_calibration_maps_its_reference_points() {
1401        // Safety: the handle is live for the whole test and freed once at the end.
1402        unsafe {
1403            let calibration = pamoja_calibration_two_point(0.0, 0.0, 1024.0, 100.0);
1404            assert!((pamoja_calibration_apply(calibration, 512.0) - 50.0).abs() < 0.01);
1405            pamoja_calibration_free(calibration);
1406        }
1407    }
1408
1409    #[test]
1410    fn a_deadband_holds_the_centre_inside_the_band() {
1411        assert_eq!(pamoja_kit_deadband(0.2, 0.0, 0.5), 0.0);
1412        assert!(pamoja_kit_deadband(1.0, 0.0, 0.5) > 0.0);
1413    }
1414
1415    #[test]
1416    fn calls_on_null_handles_are_rejected_without_dereferencing() {
1417        // Safety: every entry point tolerates a null handle.
1418        unsafe {
1419            assert!(pamoja_smoother_update(ptr::null_mut(), 1.0).is_nan());
1420            assert!(!pamoja_thermostat_update(ptr::null_mut(), 1.0));
1421            assert!(!pamoja_depletion_update(
1422                ptr::null_mut(),
1423                1.0,
1424                ptr::null_mut()
1425            ));
1426            assert!(pamoja_calibration_apply(ptr::null(), 1.0).is_nan());
1427            assert_eq!(
1428                pamoja_geofence_update(
1429                    ptr::null_mut(),
1430                    PamojaCoordinate {
1431                        latitude: 0.0,
1432                        longitude: 0.0,
1433                    }
1434                ),
1435                PamojaBoundary::Outside
1436            );
1437            // Freeing null is a documented no-op.
1438            pamoja_smoother_free(ptr::null_mut());
1439        }
1440    }
1441
1442    #[test]
1443    fn a_surge_reports_the_step_that_crosses_its_limit() {
1444        // Safety: the handle is live for the whole test and freed once at the end.
1445        unsafe {
1446            let surge = pamoja_surge_rising(5.0);
1447            let mut delta = 0.0f32;
1448            assert!(!pamoja_surge_update(surge, 10.0, &mut delta));
1449            assert!(!pamoja_surge_update(surge, 12.0, &mut delta));
1450            assert!(pamoja_surge_update(surge, 20.0, &mut delta));
1451            assert!(delta >= 5.0);
1452            pamoja_surge_free(surge);
1453        }
1454    }
1455
1456    #[test]
1457    fn a_window_reports_its_spread_once_it_holds_a_reading() {
1458        // Safety: the handle is live and the out-pointer is writable.
1459        unsafe {
1460            let window = pamoja_window_new();
1461            let mut value = 0.0f32;
1462            assert!(!pamoja_window_mean(window, &mut value), "empty has no mean");
1463
1464            for reading in [10.0f32, 20.0, 30.0] {
1465                pamoja_window_push(window, reading);
1466            }
1467            assert_eq!(pamoja_window_len(window), 3);
1468            assert_eq!(pamoja_window_capacity(window), PAMOJA_WINDOW_CAPACITY);
1469            assert!(pamoja_window_mean(window, &mut value));
1470            assert!((value - 20.0).abs() < 1e-6);
1471            assert!(pamoja_window_min(window, &mut value) && (value - 10.0).abs() < 1e-6);
1472            assert!(pamoja_window_max(window, &mut value) && (value - 30.0).abs() < 1e-6);
1473            assert!(pamoja_window_range(window, &mut value) && (value - 20.0).abs() < 1e-6);
1474            assert!(pamoja_window_variance(window, &mut value));
1475            pamoja_window_free(window);
1476        }
1477    }
1478
1479    #[test]
1480    fn a_window_drops_its_oldest_reading_once_full() {
1481        // Safety: the handle is live and the out-pointer is writable.
1482        unsafe {
1483            let window = pamoja_window_new();
1484            for index in 0..PAMOJA_WINDOW_CAPACITY + 8 {
1485                pamoja_window_push(window, index as f32);
1486            }
1487            assert_eq!(pamoja_window_len(window), PAMOJA_WINDOW_CAPACITY);
1488            let mut value = 0.0f32;
1489            assert!(pamoja_window_min(window, &mut value));
1490            assert!((value - 8.0).abs() < 1e-6, "the first eight fell out");
1491            pamoja_window_free(window);
1492        }
1493    }
1494
1495    #[test]
1496    fn a_median_rejects_the_single_wild_reading_an_average_would_follow() {
1497        // Safety: the handle is live.
1498        unsafe {
1499            let median = pamoja_median_new();
1500            for reading in [20.0f32, 21.0, 20.5] {
1501                pamoja_median_update(median, reading);
1502            }
1503            let spike = pamoja_median_update(median, 900.0);
1504            assert!(spike < 30.0, "the spike does not carry the median: {spike}");
1505            pamoja_median_free(median);
1506        }
1507    }
1508
1509    #[test]
1510    fn a_trend_reports_a_rising_signal_as_a_positive_slope() {
1511        // Safety: the handle is live and the out-pointer is writable.
1512        unsafe {
1513            let trend = pamoja_trend_new();
1514            let mut slope = 0.0f32;
1515            assert!(
1516                !pamoja_trend_slope(trend, &mut slope),
1517                "one point is no line"
1518            );
1519
1520            for reading in [1.0f32, 2.0, 3.0, 4.0] {
1521                pamoja_trend_push(trend, reading);
1522            }
1523            assert!(pamoja_trend_slope(trend, &mut slope));
1524            assert!((slope - 1.0).abs() < 1e-5, "got {slope}");
1525            pamoja_trend_free(trend);
1526        }
1527    }
1528
1529    #[test]
1530    fn an_anomaly_detector_flags_the_reading_that_stands_out() {
1531        // Safety: the handle is live.
1532        unsafe {
1533            let anomaly = pamoja_anomaly_new(3.0);
1534            for _ in 0..8 {
1535                assert!(!pamoja_anomaly_check(anomaly, 20.0));
1536            }
1537            assert!(pamoja_anomaly_check(anomaly, 900.0), "a step that far out");
1538            pamoja_anomaly_free(anomaly);
1539        }
1540    }
1541
1542    #[test]
1543    fn calls_on_null_windowed_handles_are_rejected_without_dereferencing() {
1544        let mut value = 0.0f32;
1545        // Safety: passing null is explicitly handled.
1546        unsafe {
1547            assert_eq!(pamoja_window_len(ptr::null()), 0);
1548            assert!(!pamoja_window_mean(ptr::null(), &mut value));
1549            assert!(pamoja_median_update(ptr::null_mut(), 1.0).is_nan());
1550            assert!(!pamoja_trend_slope(ptr::null(), &mut value));
1551            assert!(!pamoja_anomaly_check(ptr::null_mut(), 1.0));
1552            pamoja_window_free(ptr::null_mut());
1553            pamoja_median_free(ptr::null_mut());
1554            pamoja_trend_free(ptr::null_mut());
1555            pamoja_anomaly_free(ptr::null_mut());
1556        }
1557    }
1558}