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}