534 lines
18 KiB
Rust
534 lines
18 KiB
Rust
//! Table turnover tracking — ADR-041 Category 4: Retail & Hospitality.
|
|
//!
|
|
//! Restaurant table state machine: empty -> seated -> eating -> departing -> empty.
|
|
//! Tracks seating duration and emits turnover events.
|
|
//! Designed for single-table sensing zone per ESP32 node.
|
|
//!
|
|
//! Events (430-series):
|
|
//! - `TABLE_SEATED(430)`: Someone sat down at the table
|
|
//! - `TABLE_VACATED(431)`: Table has been vacated
|
|
//! - `TABLE_AVAILABLE(432)`: Table is clean/ready (post-vacate cooldown)
|
|
//! - `TURNOVER_RATE(433)`: Turnovers per hour (rolling)
|
|
//!
|
|
//! Host API used: presence, motion energy, n_persons.
|
|
|
|
use crate::vendor_common::Ema;
|
|
|
|
// ── Event IDs ─────────────────────────────────────────────────────────────────
|
|
|
|
pub const EVENT_TABLE_SEATED: i32 = 430;
|
|
pub const EVENT_TABLE_VACATED: i32 = 431;
|
|
pub const EVENT_TABLE_AVAILABLE: i32 = 432;
|
|
pub const EVENT_TURNOVER_RATE: i32 = 433;
|
|
|
|
// ── Configuration constants ──────────────────────────────────────────────────
|
|
|
|
/// Frame rate assumption (Hz).
|
|
const FRAME_RATE: f32 = 20.0;
|
|
|
|
/// Frames to confirm seating (debounce: ~2 seconds).
|
|
const SEATED_DEBOUNCE_FRAMES: u32 = 40;
|
|
|
|
/// Frames to confirm vacancy (debounce: ~5 seconds, avoids brief absences).
|
|
const VACATED_DEBOUNCE_FRAMES: u32 = 100;
|
|
|
|
/// Frames for table to be marked available after vacating (~30 seconds for cleanup).
|
|
const AVAILABLE_COOLDOWN_FRAMES: u32 = 600;
|
|
|
|
/// Frames per hour (at 20 Hz).
|
|
const FRAMES_PER_HOUR: u32 = 72000;
|
|
|
|
/// Motion energy threshold below which someone is "settled" (eating/sitting).
|
|
const EATING_MOTION_THRESH: f32 = 0.1;
|
|
|
|
/// Motion energy threshold above which someone is "active" (arriving/departing).
|
|
const ACTIVE_MOTION_THRESH: f32 = 0.3;
|
|
|
|
/// Reporting interval for turnover rate (~5 minutes).
|
|
const TURNOVER_REPORT_INTERVAL: u32 = 6000;
|
|
|
|
/// EMA alpha for motion smoothing.
|
|
const MOTION_EMA_ALPHA: f32 = 0.15;
|
|
|
|
/// Rolling window for turnover rate (1 hour in frames).
|
|
const TURNOVER_WINDOW_FRAMES: u32 = 72000;
|
|
|
|
/// Maximum turnovers tracked in rolling window.
|
|
const MAX_TURNOVERS: usize = 50;
|
|
|
|
/// Maximum events per frame.
|
|
const MAX_EVENTS: usize = 4;
|
|
|
|
// ── Table State ──────────────────────────────────────────────────────────────
|
|
|
|
/// State machine states for a restaurant table.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum TableState {
|
|
/// Table is empty, ready for guests.
|
|
Empty,
|
|
/// Guests are being seated (presence detected, confirming).
|
|
Seating,
|
|
/// Guests are seated and eating (low motion, sustained presence).
|
|
Eating,
|
|
/// Guests are departing (high motion, presence dropping).
|
|
Departing,
|
|
/// Table vacated, in cleanup cooldown.
|
|
Cooldown,
|
|
}
|
|
|
|
// ── Table Turnover Tracker ──────────────────────────────────────────────────
|
|
|
|
/// Tracks table occupancy state transitions and turnover metrics.
|
|
pub struct TableTurnoverTracker {
|
|
/// Current table state.
|
|
state: TableState,
|
|
/// Smoothed motion energy.
|
|
motion_ema: Ema,
|
|
/// Consecutive frames with presence (for seating confirmation).
|
|
presence_frames: u32,
|
|
/// Consecutive frames without presence (for vacancy confirmation).
|
|
absence_frames: u32,
|
|
/// Frames spent in current seating session.
|
|
session_frames: u32,
|
|
/// Cooldown counter (frames remaining).
|
|
cooldown_counter: u32,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
/// Total turnovers since reset.
|
|
total_turnovers: u32,
|
|
/// Recent turnover timestamps (frame numbers) for rate calculation.
|
|
turnover_timestamps: [u32; MAX_TURNOVERS],
|
|
/// Number of recorded turnover timestamps.
|
|
turnover_count: usize,
|
|
/// Index for circular overwrite in turnover_timestamps.
|
|
turnover_idx: usize,
|
|
/// Number of persons at the table (peak during session).
|
|
peak_persons: i32,
|
|
}
|
|
|
|
impl TableTurnoverTracker {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
state: TableState::Empty,
|
|
motion_ema: Ema::new(MOTION_EMA_ALPHA),
|
|
presence_frames: 0,
|
|
absence_frames: 0,
|
|
session_frames: 0,
|
|
cooldown_counter: 0,
|
|
frame_count: 0,
|
|
total_turnovers: 0,
|
|
turnover_timestamps: [0; MAX_TURNOVERS],
|
|
turnover_count: 0,
|
|
turnover_idx: 0,
|
|
peak_persons: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one CSI frame with host-provided signals.
|
|
///
|
|
/// - `presence`: 1 if someone is present, 0 otherwise
|
|
/// - `motion_energy`: aggregate motion energy
|
|
/// - `n_persons`: estimated person count
|
|
///
|
|
/// Returns event slice `&[(event_type, value)]`.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
presence: i32,
|
|
motion_energy: f32,
|
|
n_persons: i32,
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
|
|
let is_present = presence > 0 || n_persons > 0;
|
|
let smoothed_motion = self.motion_ema.update(motion_energy);
|
|
let n = if n_persons < 0 { 0 } else { n_persons };
|
|
|
|
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
|
|
let mut ne = 0usize;
|
|
|
|
match self.state {
|
|
TableState::Empty => {
|
|
if is_present {
|
|
self.presence_frames += 1;
|
|
if self.presence_frames >= SEATED_DEBOUNCE_FRAMES {
|
|
// Transition: Empty -> Seating confirmed -> Eating.
|
|
self.state = TableState::Eating;
|
|
self.session_frames = 0;
|
|
self.peak_persons = n;
|
|
self.absence_frames = 0;
|
|
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TABLE_SEATED, n as f32);
|
|
}
|
|
ne += 1;
|
|
}
|
|
}
|
|
} else {
|
|
self.presence_frames = 0;
|
|
}
|
|
}
|
|
|
|
TableState::Seating => {
|
|
// This state is implicit (handled in Empty -> Eating transition).
|
|
// Keeping for completeness; actual logic uses Empty with debounce.
|
|
self.state = TableState::Eating;
|
|
}
|
|
|
|
TableState::Eating => {
|
|
self.session_frames += 1;
|
|
|
|
// Track peak persons.
|
|
if n > self.peak_persons {
|
|
self.peak_persons = n;
|
|
}
|
|
|
|
if !is_present {
|
|
self.absence_frames += 1;
|
|
if self.absence_frames >= VACATED_DEBOUNCE_FRAMES {
|
|
// Transition: Eating -> Departing -> Cooldown.
|
|
self.state = TableState::Cooldown;
|
|
self.cooldown_counter = AVAILABLE_COOLDOWN_FRAMES;
|
|
self.total_turnovers += 1;
|
|
|
|
// Record turnover timestamp.
|
|
self.turnover_timestamps[self.turnover_idx] = self.frame_count;
|
|
self.turnover_idx = (self.turnover_idx + 1) % MAX_TURNOVERS;
|
|
if self.turnover_count < MAX_TURNOVERS {
|
|
self.turnover_count += 1;
|
|
}
|
|
|
|
// Duration in seconds.
|
|
let duration_s = self.session_frames as f32 / FRAME_RATE;
|
|
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TABLE_VACATED, duration_s);
|
|
}
|
|
ne += 1;
|
|
}
|
|
|
|
self.session_frames = 0;
|
|
self.absence_frames = 0;
|
|
}
|
|
} else {
|
|
self.absence_frames = 0;
|
|
|
|
// Detect departing behavior: high motion while presence drops.
|
|
if smoothed_motion > ACTIVE_MOTION_THRESH && n < self.peak_persons {
|
|
// Guests may be leaving, but wait for actual absence.
|
|
self.state = TableState::Departing;
|
|
}
|
|
}
|
|
}
|
|
|
|
TableState::Departing => {
|
|
self.session_frames += 1;
|
|
|
|
if !is_present {
|
|
self.absence_frames += 1;
|
|
if self.absence_frames >= VACATED_DEBOUNCE_FRAMES {
|
|
self.state = TableState::Cooldown;
|
|
self.cooldown_counter = AVAILABLE_COOLDOWN_FRAMES;
|
|
self.total_turnovers += 1;
|
|
|
|
let turnover_frame = self.frame_count;
|
|
self.turnover_timestamps[self.turnover_idx] = turnover_frame;
|
|
self.turnover_idx = (self.turnover_idx + 1) % MAX_TURNOVERS;
|
|
if self.turnover_count < MAX_TURNOVERS {
|
|
self.turnover_count += 1;
|
|
}
|
|
|
|
let duration_s = self.session_frames as f32 / FRAME_RATE;
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TABLE_VACATED, duration_s);
|
|
}
|
|
ne += 1;
|
|
}
|
|
|
|
self.session_frames = 0;
|
|
self.absence_frames = 0;
|
|
}
|
|
} else {
|
|
self.absence_frames = 0;
|
|
// If motion settles, return to Eating.
|
|
if smoothed_motion < EATING_MOTION_THRESH {
|
|
self.state = TableState::Eating;
|
|
}
|
|
}
|
|
}
|
|
|
|
TableState::Cooldown => {
|
|
if self.cooldown_counter > 0 {
|
|
self.cooldown_counter -= 1;
|
|
}
|
|
|
|
if self.cooldown_counter == 0 {
|
|
self.state = TableState::Empty;
|
|
self.presence_frames = 0;
|
|
self.peak_persons = 0;
|
|
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TABLE_AVAILABLE, 1.0);
|
|
}
|
|
ne += 1;
|
|
}
|
|
} else if is_present {
|
|
// Someone sat down during cleanup — fast transition back.
|
|
self.presence_frames += 1;
|
|
if self.presence_frames >= SEATED_DEBOUNCE_FRAMES / 2 {
|
|
self.state = TableState::Eating;
|
|
self.session_frames = 0;
|
|
self.peak_persons = n;
|
|
self.presence_frames = 0;
|
|
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TABLE_SEATED, n as f32);
|
|
}
|
|
ne += 1;
|
|
}
|
|
}
|
|
} else {
|
|
self.presence_frames = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periodic turnover rate report.
|
|
if self.frame_count % TURNOVER_REPORT_INTERVAL == 0 && self.frame_count > 0 {
|
|
let rate = self.turnover_rate();
|
|
if ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TURNOVER_RATE, rate);
|
|
}
|
|
ne += 1;
|
|
}
|
|
}
|
|
|
|
unsafe { &EVENTS[..ne] }
|
|
}
|
|
|
|
/// Compute turnovers per hour (rolling window).
|
|
pub fn turnover_rate(&self) -> f32 {
|
|
if self.turnover_count == 0 || self.frame_count < 100 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Count turnovers within the last hour.
|
|
let window_start = if self.frame_count > TURNOVER_WINDOW_FRAMES {
|
|
self.frame_count - TURNOVER_WINDOW_FRAMES
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let mut count = 0u32;
|
|
for i in 0..self.turnover_count {
|
|
if self.turnover_timestamps[i] >= window_start {
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
// Scale to per-hour rate.
|
|
let elapsed_hours = self.frame_count as f32 / FRAMES_PER_HOUR as f32;
|
|
let window_hours = if elapsed_hours < 1.0 { elapsed_hours } else { 1.0 };
|
|
|
|
if window_hours > 0.001 {
|
|
count as f32 / window_hours
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
/// Get current table state.
|
|
pub fn state(&self) -> TableState {
|
|
self.state
|
|
}
|
|
|
|
/// Get total turnovers.
|
|
pub fn total_turnovers(&self) -> u32 {
|
|
self.total_turnovers
|
|
}
|
|
|
|
/// Get session duration in seconds (0 if not in a session).
|
|
pub fn session_duration_s(&self) -> f32 {
|
|
match self.state {
|
|
TableState::Eating | TableState::Departing => {
|
|
self.session_frames as f32 / FRAME_RATE
|
|
}
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_init_state() {
|
|
let tt = TableTurnoverTracker::new();
|
|
assert_eq!(tt.state(), TableState::Empty);
|
|
assert_eq!(tt.total_turnovers(), 0);
|
|
assert!(tt.session_duration_s() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_seated_after_debounce() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
let mut seated_event = false;
|
|
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
let events = tt.process_frame(1, 0.2, 2);
|
|
for &(et, _) in events {
|
|
if et == EVENT_TABLE_SEATED {
|
|
seated_event = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(seated_event, "TABLE_SEATED should fire after debounce period");
|
|
assert_eq!(tt.state(), TableState::Eating);
|
|
}
|
|
|
|
#[test]
|
|
fn test_vacated_after_absence() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
|
|
// Seat guests.
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(1, 0.05, 2);
|
|
}
|
|
assert_eq!(tt.state(), TableState::Eating);
|
|
|
|
// Guests leave.
|
|
let mut vacated_event = false;
|
|
for _ in 0..VACATED_DEBOUNCE_FRAMES + 1 {
|
|
let events = tt.process_frame(0, 0.0, 0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_TABLE_VACATED {
|
|
vacated_event = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(vacated_event, "TABLE_VACATED should fire after absence debounce");
|
|
assert_eq!(tt.state(), TableState::Cooldown);
|
|
assert_eq!(tt.total_turnovers(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_available_after_cooldown() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
|
|
// Seat + vacate.
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(1, 0.05, 2);
|
|
}
|
|
for _ in 0..VACATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(0, 0.0, 0);
|
|
}
|
|
assert_eq!(tt.state(), TableState::Cooldown);
|
|
|
|
// Wait for cooldown.
|
|
let mut available_event = false;
|
|
for _ in 0..AVAILABLE_COOLDOWN_FRAMES + 1 {
|
|
let events = tt.process_frame(0, 0.0, 0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_TABLE_AVAILABLE {
|
|
available_event = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(available_event, "TABLE_AVAILABLE should fire after cooldown");
|
|
assert_eq!(tt.state(), TableState::Empty);
|
|
}
|
|
|
|
#[test]
|
|
fn test_brief_absence_doesnt_vacate() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
|
|
// Seat guests.
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(1, 0.05, 2);
|
|
}
|
|
assert_eq!(tt.state(), TableState::Eating);
|
|
|
|
// Brief absence (shorter than debounce).
|
|
for _ in 0..VACATED_DEBOUNCE_FRAMES / 2 {
|
|
tt.process_frame(0, 0.0, 0);
|
|
}
|
|
|
|
// Presence returns.
|
|
tt.process_frame(1, 0.05, 2);
|
|
|
|
// Should still be in Eating, not vacated.
|
|
assert!(
|
|
tt.state() == TableState::Eating || tt.state() == TableState::Departing,
|
|
"brief absence should not trigger vacate, got {:?}", tt.state()
|
|
);
|
|
assert_eq!(tt.total_turnovers(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_turnover_rate_computation() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
|
|
// Simulate two full turnover cycles.
|
|
for _ in 0..2 {
|
|
// Seat.
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(1, 0.05, 2);
|
|
}
|
|
// Eat for a while.
|
|
for _ in 0..200 {
|
|
tt.process_frame(1, 0.03, 2);
|
|
}
|
|
// Vacate.
|
|
for _ in 0..VACATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(0, 0.0, 0);
|
|
}
|
|
// Cooldown.
|
|
for _ in 0..AVAILABLE_COOLDOWN_FRAMES + 1 {
|
|
tt.process_frame(0, 0.0, 0);
|
|
}
|
|
}
|
|
|
|
assert_eq!(tt.total_turnovers(), 2);
|
|
let rate = tt.turnover_rate();
|
|
assert!(rate > 0.0, "turnover rate should be positive, got {}", rate);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_duration() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
|
|
// Seat guests.
|
|
for _ in 0..SEATED_DEBOUNCE_FRAMES + 1 {
|
|
tt.process_frame(1, 0.05, 2);
|
|
}
|
|
|
|
// Stay for 200 frames (10 seconds at 20 Hz).
|
|
for _ in 0..200 {
|
|
tt.process_frame(1, 0.03, 2);
|
|
}
|
|
|
|
let duration = tt.session_duration_s();
|
|
assert!(duration > 9.0 && duration < 12.0,
|
|
"session duration should be ~10s, got {}", duration);
|
|
}
|
|
|
|
#[test]
|
|
fn test_negative_inputs() {
|
|
let mut tt = TableTurnoverTracker::new();
|
|
// Should not panic with negative inputs.
|
|
let _events = tt.process_frame(-1, -0.5, -3);
|
|
assert_eq!(tt.state(), TableState::Empty);
|
|
}
|
|
}
|