pamoja_sim/sensor.rs
1//! Fake sensors that stand in for real hardware.
2
3use pamoja_core::{Error, Result, Sensor};
4
5/// A fake sensor that generates a signal from a baseline, drift, and noise.
6///
7/// This is the workhorse for hardware-free development: it implements the core
8/// [`Sensor`] trait, so it drops into a `Node`, a profile, or any test exactly where
9/// a real probe would, and it produces readings that look like the field rather than
10/// a clean constant. A reading is the baseline plus any accumulated drift plus a
11/// bounded pseudo-random wobble, so a control loop can be exercised against a signal
12/// that warms, sags, or jitters the way a real one does.
13///
14/// The noise is deterministic for a given seed - a small xorshift generator drives
15/// it, with no `rand` dependency - so a test that uses a `SimSensor` produces the
16/// same sequence every run and stays reproducible in CI.
17///
18/// # Examples
19///
20/// A noisy thermometer that warms by 0.1 degrees each reading:
21///
22/// ```
23/// use pamoja_core::Sensor;
24/// use pamoja_sim::SimSensor;
25///
26/// # async fn demo() -> pamoja_core::Result<()> {
27/// let mut probe = SimSensor::new(20.0).with_drift(0.1).with_noise(0.05).with_seed(7);
28/// let first = probe.read().await?;
29/// assert!((first - 20.0).abs() <= 0.05); // the first reading sits near the baseline
30/// # Ok(())
31/// # }
32/// ```
33#[derive(Clone, Copy, Debug)]
34pub struct SimSensor {
35 value: f32,
36 drift: f32,
37 noise: f32,
38 rng: u32,
39}
40
41impl SimSensor {
42 /// Creates a sensor that reads `baseline` with no drift or noise.
43 ///
44 /// # Arguments
45 ///
46 /// * `baseline` - the value the sensor reads before drift and noise are added.
47 ///
48 /// # Returns
49 ///
50 /// A steady sensor; add [`with_drift`](SimSensor::with_drift) and
51 /// [`with_noise`](SimSensor::with_noise) to make it lifelike.
52 pub fn new(baseline: f32) -> Self {
53 Self {
54 value: baseline,
55 drift: 0.0,
56 noise: 0.0,
57 rng: 0x9E37_79B9,
58 }
59 }
60
61 /// Sets how much the baseline moves each reading, modelling a slow trend.
62 ///
63 /// # Arguments
64 ///
65 /// * `per_read` - the amount added to the baseline after each reading; negative
66 /// values sag the signal downward.
67 ///
68 /// # Returns
69 ///
70 /// The updated sensor, for chaining.
71 pub fn with_drift(mut self, per_read: f32) -> Self {
72 self.drift = per_read;
73 self
74 }
75
76 /// Sets the amplitude of the bounded noise added to each reading.
77 ///
78 /// # Arguments
79 ///
80 /// * `amplitude` - the largest magnitude the noise can reach; its magnitude is
81 /// used, and each reading wobbles within plus or minus this amount.
82 ///
83 /// # Returns
84 ///
85 /// The updated sensor, for chaining.
86 pub fn with_noise(mut self, amplitude: f32) -> Self {
87 self.noise = magnitude(amplitude);
88 self
89 }
90
91 /// Sets the seed for the noise generator, making a run reproducible.
92 ///
93 /// # Arguments
94 ///
95 /// * `seed` - the generator seed; zero is replaced with a fixed non-zero value,
96 /// since the xorshift generator cannot start from zero.
97 ///
98 /// # Returns
99 ///
100 /// The updated sensor, for chaining.
101 pub fn with_seed(mut self, seed: u32) -> Self {
102 self.rng = if seed == 0 { 1 } else { seed };
103 self
104 }
105
106 // Advances the xorshift generator and maps it to bounded noise.
107 fn next_noise(&mut self) -> f32 {
108 if self.noise == 0.0 {
109 return 0.0;
110 }
111 let mut x = self.rng;
112 x ^= x << 13;
113 x ^= x >> 17;
114 x ^= x << 5;
115 self.rng = x;
116 let unit = (x as f32 / u32::MAX as f32) * 2.0 - 1.0; // [-1.0, 1.0)
117 unit * self.noise
118 }
119}
120
121impl Sensor for SimSensor {
122 type Reading = f32;
123
124 async fn read(&mut self) -> Result<f32> {
125 let reading = self.value + self.next_noise();
126 self.value += self.drift;
127 Ok(reading)
128 }
129}
130
131/// A fake sensor that replays a fixed sequence of readings.
132///
133/// Where a [`SimSensor`] generates a signal, a `Replay` plays back exact values in
134/// order, which is what a deterministic test or a scripted demo wants: spell out the
135/// readings that tell the story, and the sensor yields them one per
136/// [`read`](Sensor::read). A one-shot replay reports [`Error::Closed`] once the
137/// sequence is exhausted; a repeating one loops forever.
138///
139/// # Examples
140///
141/// ```
142/// use pamoja_core::Sensor;
143/// use pamoja_sim::Replay;
144///
145/// # async fn demo() -> pamoja_core::Result<()> {
146/// let mut gauge = Replay::new(vec![1.0, 1.2, 1.9]);
147/// assert_eq!(gauge.read().await?, 1.0);
148/// assert_eq!(gauge.read().await?, 1.2);
149/// # Ok(())
150/// # }
151/// ```
152#[derive(Clone, Debug)]
153pub struct Replay {
154 readings: Vec<f32>,
155 index: usize,
156 repeat: bool,
157}
158
159impl Replay {
160 /// Creates a sensor that yields `readings` once, then reports closed.
161 ///
162 /// # Arguments
163 ///
164 /// * `readings` - the values to play back in order.
165 ///
166 /// # Returns
167 ///
168 /// A one-shot replay sensor.
169 pub fn new(readings: Vec<f32>) -> Self {
170 Self {
171 readings,
172 index: 0,
173 repeat: false,
174 }
175 }
176
177 /// Creates a sensor that yields `readings` in a loop forever.
178 ///
179 /// # Arguments
180 ///
181 /// * `readings` - the values to play back in order, repeating from the start.
182 ///
183 /// # Returns
184 ///
185 /// A repeating replay sensor.
186 pub fn repeating(readings: Vec<f32>) -> Self {
187 Self {
188 readings,
189 index: 0,
190 repeat: true,
191 }
192 }
193}
194
195impl Sensor for Replay {
196 type Reading = f32;
197
198 async fn read(&mut self) -> Result<f32> {
199 if self.index >= self.readings.len() {
200 if self.repeat && !self.readings.is_empty() {
201 self.index = 0;
202 } else {
203 return Err(Error::Closed);
204 }
205 }
206 let reading = self.readings[self.index];
207 self.index += 1;
208 Ok(reading)
209 }
210}
211
212// `f32::abs` lives in `std`, but a hand-rolled magnitude keeps this consistent with
213// the rest of the SDK's pure-logic crates.
214fn magnitude(value: f32) -> f32 {
215 if value < 0.0 {
216 -value
217 } else {
218 value
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[tokio::test]
227 async fn a_plain_sensor_returns_its_baseline() {
228 let mut sensor = SimSensor::new(3.5);
229 assert_eq!(sensor.read().await.unwrap(), 3.5);
230 assert_eq!(sensor.read().await.unwrap(), 3.5);
231 }
232
233 #[tokio::test]
234 async fn drift_accumulates_each_reading() {
235 let mut sensor = SimSensor::new(10.0).with_drift(2.0);
236 assert_eq!(sensor.read().await.unwrap(), 10.0);
237 assert_eq!(sensor.read().await.unwrap(), 12.0);
238 assert_eq!(sensor.read().await.unwrap(), 14.0);
239 }
240
241 #[tokio::test]
242 async fn the_same_seed_replays_the_same_noise() {
243 let mut a = SimSensor::new(20.0).with_noise(0.5).with_seed(7);
244 let mut b = SimSensor::new(20.0).with_noise(0.5).with_seed(7);
245 for _ in 0..16 {
246 assert_eq!(a.read().await.unwrap(), b.read().await.unwrap());
247 }
248 }
249
250 #[tokio::test]
251 async fn noise_stays_within_its_amplitude() {
252 let mut sensor = SimSensor::new(20.0).with_noise(0.5).with_seed(99);
253 for _ in 0..1000 {
254 let reading = sensor.read().await.unwrap();
255 assert!((reading - 20.0).abs() <= 0.5 + f32::EPSILON);
256 }
257 }
258
259 #[tokio::test]
260 async fn replay_yields_readings_in_order_then_closes() {
261 let mut sensor = Replay::new(vec![1.0, 2.0]);
262 assert_eq!(sensor.read().await.unwrap(), 1.0);
263 assert_eq!(sensor.read().await.unwrap(), 2.0);
264 assert!(matches!(sensor.read().await, Err(Error::Closed)));
265 }
266
267 #[tokio::test]
268 async fn a_repeating_replay_loops() {
269 let mut sensor = Replay::repeating(vec![1.0, 2.0]);
270 assert_eq!(sensor.read().await.unwrap(), 1.0);
271 assert_eq!(sensor.read().await.unwrap(), 2.0);
272 assert_eq!(sensor.read().await.unwrap(), 1.0);
273 }
274
275 #[tokio::test]
276 async fn an_empty_replay_is_closed() {
277 let mut sensor = Replay::repeating(vec![]);
278 assert!(matches!(sensor.read().await, Err(Error::Closed)));
279 }
280}