404 lines
12 KiB
Rust
404 lines
12 KiB
Rust
//! Livestock monitoring — ADR-041 Category 5 Industrial module.
|
|
//!
|
|
//! Animal presence and health monitoring in agricultural settings using
|
|
//! WiFi CSI sensing.
|
|
//!
|
|
//! Features:
|
|
//! - Presence detection for animals in pens/barns
|
|
//! - Abnormal stillness detection (possible illness)
|
|
//! - Labored breathing detection (species-configurable BPM ranges)
|
|
//! - Escape alert (sudden presence loss after confirmed occupancy)
|
|
//!
|
|
//! Species breathing ranges (BPM):
|
|
//! - Cattle: 12-30
|
|
//! - Sheep: 12-20
|
|
//! - Poultry: 15-30
|
|
//!
|
|
//! Budget: L (<2 ms per frame). Event IDs 530-533.
|
|
|
|
/// Minimum motion energy to be considered "active".
|
|
const MIN_MOTION_ACTIVE: f32 = 0.03;
|
|
|
|
/// Abnormal stillness threshold (frames at 20 Hz).
|
|
/// 5 minutes = 6000 frames. Animals rarely stay completely motionless
|
|
/// for this long unless ill.
|
|
const STILLNESS_FRAMES: u32 = 6000;
|
|
|
|
/// Escape detection: sudden absence after N frames of confirmed presence.
|
|
/// 10 seconds of confirmed presence before escape counts.
|
|
const MIN_PRESENCE_FOR_ESCAPE: u32 = 200;
|
|
|
|
/// Absence frames before triggering escape alert (1 second at 20 Hz).
|
|
const ESCAPE_ABSENCE_FRAMES: u32 = 20;
|
|
|
|
/// Labored breathing debounce (frames).
|
|
const LABORED_DEBOUNCE: u8 = 20;
|
|
|
|
/// Stillness alert debounce (fire once, then cooldown).
|
|
const STILLNESS_COOLDOWN: u32 = 6000;
|
|
|
|
/// Escape alert cooldown (frames).
|
|
const ESCAPE_COOLDOWN: u16 = 400;
|
|
|
|
/// Presence report interval (frames, ~10 seconds).
|
|
const PRESENCE_REPORT_INTERVAL: u32 = 200;
|
|
|
|
/// Event IDs (530-series: Industrial/Livestock).
|
|
pub const EVENT_ANIMAL_PRESENT: i32 = 530;
|
|
pub const EVENT_ABNORMAL_STILLNESS: i32 = 531;
|
|
pub const EVENT_LABORED_BREATHING: i32 = 532;
|
|
pub const EVENT_ESCAPE_ALERT: i32 = 533;
|
|
|
|
/// Species type for breathing range configuration.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Species {
|
|
Cattle,
|
|
Sheep,
|
|
Poultry,
|
|
Custom { min_bpm: f32, max_bpm: f32 },
|
|
}
|
|
|
|
impl Species {
|
|
/// Normal breathing range (min, max) in BPM.
|
|
pub const fn breathing_range(&self) -> (f32, f32) {
|
|
match self {
|
|
Species::Cattle => (12.0, 30.0),
|
|
Species::Sheep => (12.0, 20.0),
|
|
Species::Poultry => (15.0, 30.0),
|
|
Species::Custom { min_bpm, max_bpm } => (*min_bpm, *max_bpm),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Livestock monitor.
|
|
pub struct LivestockMonitor {
|
|
/// Configured species.
|
|
species: Species,
|
|
/// Whether animal is currently detected (debounced).
|
|
animal_present: bool,
|
|
/// Consecutive frames with presence.
|
|
presence_frames: u32,
|
|
/// Consecutive frames without presence (after confirmed).
|
|
absence_frames: u32,
|
|
/// Consecutive frames without motion.
|
|
still_frames: u32,
|
|
/// Labored breathing debounce counter.
|
|
labored_debounce: u8,
|
|
/// Stillness alert fired flag.
|
|
stillness_alerted: bool,
|
|
/// Escape cooldown counter.
|
|
escape_cooldown: u16,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
/// Last reported breathing BPM.
|
|
last_bpm: f32,
|
|
}
|
|
|
|
impl LivestockMonitor {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
species: Species::Cattle,
|
|
animal_present: false,
|
|
presence_frames: 0,
|
|
absence_frames: 0,
|
|
still_frames: 0,
|
|
labored_debounce: 0,
|
|
stillness_alerted: false,
|
|
escape_cooldown: 0,
|
|
frame_count: 0,
|
|
last_bpm: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Create with a specific species.
|
|
pub const fn with_species(species: Species) -> Self {
|
|
Self {
|
|
species,
|
|
animal_present: false,
|
|
presence_frames: 0,
|
|
absence_frames: 0,
|
|
still_frames: 0,
|
|
labored_debounce: 0,
|
|
stillness_alerted: false,
|
|
escape_cooldown: 0,
|
|
frame_count: 0,
|
|
last_bpm: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Process one frame.
|
|
///
|
|
/// # Arguments
|
|
/// - `presence`: host-reported presence flag (0/1)
|
|
/// - `breathing_bpm`: host-reported breathing rate
|
|
/// - `motion_energy`: host-reported motion energy
|
|
/// - `variance`: mean CSI variance
|
|
///
|
|
/// Returns events as `(event_id, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
presence: i32,
|
|
breathing_bpm: f32,
|
|
motion_energy: f32,
|
|
_variance: f32,
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
|
|
if self.escape_cooldown > 0 {
|
|
self.escape_cooldown -= 1;
|
|
}
|
|
|
|
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
|
|
let mut n_events = 0usize;
|
|
|
|
let raw_present = presence > 0 || motion_energy > MIN_MOTION_ACTIVE;
|
|
|
|
// --- Step 1: Presence tracking ---
|
|
if raw_present {
|
|
self.presence_frames += 1;
|
|
self.absence_frames = 0;
|
|
if !self.animal_present && self.presence_frames >= 10 {
|
|
self.animal_present = true;
|
|
self.still_frames = 0;
|
|
self.stillness_alerted = false;
|
|
}
|
|
} else {
|
|
self.absence_frames += 1;
|
|
// Only reset presence after sustained absence.
|
|
if self.absence_frames >= ESCAPE_ABSENCE_FRAMES {
|
|
let was_present = self.animal_present;
|
|
let had_enough_presence = self.presence_frames >= MIN_PRESENCE_FOR_ESCAPE;
|
|
self.animal_present = false;
|
|
|
|
// Escape alert: was present for a while, then suddenly gone.
|
|
if was_present && had_enough_presence
|
|
&& self.escape_cooldown == 0
|
|
&& n_events < 4
|
|
{
|
|
self.escape_cooldown = ESCAPE_COOLDOWN;
|
|
let minutes_present = self.presence_frames as f32 / (20.0 * 60.0);
|
|
unsafe { EVENTS[n_events] = (EVENT_ESCAPE_ALERT, minutes_present); }
|
|
n_events += 1;
|
|
}
|
|
|
|
self.presence_frames = 0;
|
|
}
|
|
}
|
|
|
|
// --- Step 2: Periodic presence report ---
|
|
if self.animal_present
|
|
&& self.frame_count % PRESENCE_REPORT_INTERVAL == 0
|
|
&& n_events < 4
|
|
{
|
|
unsafe { EVENTS[n_events] = (EVENT_ANIMAL_PRESENT, breathing_bpm); }
|
|
n_events += 1;
|
|
}
|
|
|
|
// --- Step 3: Stillness detection (only when animal is present) ---
|
|
if self.animal_present {
|
|
if motion_energy < MIN_MOTION_ACTIVE {
|
|
self.still_frames += 1;
|
|
} else {
|
|
self.still_frames = 0;
|
|
self.stillness_alerted = false;
|
|
}
|
|
|
|
if self.still_frames >= STILLNESS_FRAMES
|
|
&& !self.stillness_alerted
|
|
&& n_events < 4
|
|
{
|
|
self.stillness_alerted = true;
|
|
let minutes_still = self.still_frames as f32 / (20.0 * 60.0);
|
|
unsafe { EVENTS[n_events] = (EVENT_ABNORMAL_STILLNESS, minutes_still); }
|
|
n_events += 1;
|
|
}
|
|
}
|
|
|
|
// --- Step 4: Labored breathing detection ---
|
|
if self.animal_present && breathing_bpm > 0.5 {
|
|
self.last_bpm = breathing_bpm;
|
|
let (min_bpm, max_bpm) = self.species.breathing_range();
|
|
|
|
// Labored: either too fast or too slow.
|
|
let is_labored = breathing_bpm < min_bpm * 0.7
|
|
|| breathing_bpm > max_bpm * 1.3;
|
|
|
|
if is_labored {
|
|
self.labored_debounce = self.labored_debounce.saturating_add(1);
|
|
if self.labored_debounce >= LABORED_DEBOUNCE && n_events < 4 {
|
|
unsafe { EVENTS[n_events] = (EVENT_LABORED_BREATHING, breathing_bpm); }
|
|
n_events += 1;
|
|
self.labored_debounce = 0; // Reset to allow repeated alerts.
|
|
}
|
|
} else {
|
|
self.labored_debounce = 0;
|
|
}
|
|
}
|
|
|
|
unsafe { &EVENTS[..n_events] }
|
|
}
|
|
|
|
/// Whether an animal is currently detected.
|
|
pub fn is_animal_present(&self) -> bool {
|
|
self.animal_present
|
|
}
|
|
|
|
/// Configured species.
|
|
pub fn species(&self) -> Species {
|
|
self.species
|
|
}
|
|
|
|
/// Minutes of stillness (at 20 Hz frame rate).
|
|
pub fn stillness_minutes(&self) -> f32 {
|
|
self.still_frames as f32 / (20.0 * 60.0)
|
|
}
|
|
|
|
/// Last observed breathing BPM.
|
|
pub fn last_breathing_bpm(&self) -> f32 {
|
|
self.last_bpm
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_init_state() {
|
|
let mon = LivestockMonitor::new();
|
|
assert!(!mon.is_animal_present());
|
|
assert_eq!(mon.frame_count, 0);
|
|
assert!((mon.stillness_minutes() - 0.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_species_breathing_ranges() {
|
|
assert_eq!(Species::Cattle.breathing_range(), (12.0, 30.0));
|
|
assert_eq!(Species::Sheep.breathing_range(), (12.0, 20.0));
|
|
assert_eq!(Species::Poultry.breathing_range(), (15.0, 30.0));
|
|
|
|
let custom = Species::Custom { min_bpm: 8.0, max_bpm: 25.0 };
|
|
assert_eq!(custom.breathing_range(), (8.0, 25.0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_animal_presence_detection() {
|
|
let mut mon = LivestockMonitor::new();
|
|
|
|
// Feed presence frames.
|
|
for _ in 0..20 {
|
|
mon.process_frame(1, 20.0, 0.1, 0.05);
|
|
}
|
|
|
|
assert!(mon.is_animal_present(), "animal should be detected after sustained presence");
|
|
}
|
|
|
|
#[test]
|
|
fn test_labored_breathing_cattle() {
|
|
let mut mon = LivestockMonitor::with_species(Species::Cattle);
|
|
|
|
// Establish presence.
|
|
for _ in 0..20 {
|
|
mon.process_frame(1, 20.0, 0.1, 0.05);
|
|
}
|
|
|
|
// Feed abnormally high breathing (>30*1.3 = 39 BPM for cattle).
|
|
let mut labored_detected = false;
|
|
for _ in 0..30 {
|
|
let events = mon.process_frame(1, 45.0, 0.1, 0.05);
|
|
for &(et, val) in events {
|
|
if et == EVENT_LABORED_BREATHING {
|
|
labored_detected = true;
|
|
assert!((val - 45.0).abs() < 0.01);
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(labored_detected, "labored breathing should be detected for cattle at 45 BPM");
|
|
}
|
|
|
|
#[test]
|
|
fn test_normal_breathing_no_alert() {
|
|
let mut mon = LivestockMonitor::with_species(Species::Cattle);
|
|
|
|
// Establish presence with normal breathing.
|
|
for _ in 0..100 {
|
|
let events = mon.process_frame(1, 20.0, 0.1, 0.05);
|
|
for &(et, _) in events {
|
|
assert!(et != EVENT_LABORED_BREATHING, "no labored breathing at 20 BPM for cattle");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_escape_alert() {
|
|
let mut mon = LivestockMonitor::new();
|
|
|
|
// Establish strong presence (>MIN_PRESENCE_FOR_ESCAPE frames).
|
|
for _ in 0..250 {
|
|
mon.process_frame(1, 20.0, 0.1, 0.05);
|
|
}
|
|
assert!(mon.is_animal_present());
|
|
|
|
// Suddenly no presence.
|
|
let mut escape_detected = false;
|
|
for _ in 0..40 {
|
|
let events = mon.process_frame(0, 0.0, 0.0, 0.001);
|
|
for &(et, _) in events {
|
|
if et == EVENT_ESCAPE_ALERT {
|
|
escape_detected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(escape_detected, "escape alert should fire after sudden absence");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sheep_low_breathing_labored() {
|
|
let mut mon = LivestockMonitor::with_species(Species::Sheep);
|
|
|
|
// Establish presence.
|
|
for _ in 0..20 {
|
|
mon.process_frame(1, 16.0, 0.1, 0.05);
|
|
}
|
|
|
|
// Feed very low breathing for sheep (<12*0.7 = 8.4 BPM).
|
|
let mut labored_detected = false;
|
|
for _ in 0..30 {
|
|
let events = mon.process_frame(1, 6.0, 0.1, 0.05);
|
|
for &(et, _) in events {
|
|
if et == EVENT_LABORED_BREATHING {
|
|
labored_detected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(labored_detected, "labored breathing should be detected for sheep at 6 BPM");
|
|
}
|
|
|
|
#[test]
|
|
fn test_abnormal_stillness() {
|
|
let mut mon = LivestockMonitor::new();
|
|
|
|
// Establish presence with motion.
|
|
for _ in 0..20 {
|
|
mon.process_frame(1, 20.0, 0.1, 0.05);
|
|
}
|
|
|
|
// Animal present but no motion for a long time.
|
|
let mut stillness_detected = false;
|
|
for _ in 0..6100 {
|
|
// Keep presence via breathing BPM check, but no motion.
|
|
let events = mon.process_frame(1, 18.0, 0.001, 0.05);
|
|
for &(et, _) in events {
|
|
if et == EVENT_ABNORMAL_STILLNESS {
|
|
stillness_detected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(stillness_detected, "abnormal stillness should be detected after 5 minutes");
|
|
}
|
|
}
|