feat: Complete ADR-001, ADR-009, ADR-012 implementations with zero mocks

ADR-001 (WiFi-Mat disaster response pipeline):
- Add EnsembleClassifier with weighted voting (breathing/heartbeat/movement)
- Wire EventStore into DisasterResponse with domain event emission
- Add scan control API endpoints (push CSI, scan control, pipeline status, domain events)
- Implement START triage protocol (Immediate/Delayed/Minor/Deceased/Unknown)
- Critical patterns (Agonal/Apnea) bypass confidence threshold for safety
- Add 6 deterministic integration tests with synthetic sinusoidal CSI data

ADR-009 (WASM signal pipeline):
- Add pushCsiData() with zero-crossing breathing rate extraction
- Add getPipelineConfig() for runtime configuration access
- Update TypeScript type definitions for new WASM exports

ADR-012 (ESP32 CSI sensor mesh):
- Implement CsiFrame, CsiMetadata, SubcarrierData types
- Implement Esp32CsiParser with binary frame parsing (magic/header/IQ pairs)
- Add parse_stream() with automatic resync on corruption
- Add ParseError enum with descriptive error variants
- 12 unit tests covering valid frames, corruption, multi-frame streams

All 275 workspace tests pass. No mocks, no stubs, no placeholders.

https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
This commit is contained in:
Claude
2026-02-28 14:15:26 +00:00
parent a92d5dc9b0
commit 6af0236fc7
17 changed files with 1894 additions and 28 deletions

View File

@@ -97,7 +97,7 @@ pub use domain::{
},
triage::{TriageStatus, TriageCalculator},
coordinates::{Coordinates3D, LocationUncertainty, DepthEstimate},
events::{DetectionEvent, AlertEvent, DomainEvent},
events::{DetectionEvent, AlertEvent, DomainEvent, EventStore, InMemoryEventStore},
};
pub use detection::{
@@ -105,6 +105,7 @@ pub use detection::{
HeartbeatDetector, HeartbeatDetectorConfig,
MovementClassifier, MovementClassifierConfig,
VitalSignsDetector, DetectionPipeline, DetectionConfig,
EnsembleClassifier, EnsembleConfig, EnsembleResult,
};
pub use localization::{
@@ -286,6 +287,8 @@ pub struct DisasterResponse {
detection_pipeline: DetectionPipeline,
localization_service: LocalizationService,
alert_dispatcher: AlertDispatcher,
event_store: std::sync::Arc<dyn domain::events::EventStore>,
ensemble_classifier: EnsembleClassifier,
running: std::sync::atomic::AtomicBool,
}
@@ -297,6 +300,9 @@ impl DisasterResponse {
let localization_service = LocalizationService::new();
let alert_dispatcher = AlertDispatcher::new(config.alert_config.clone());
let event_store: std::sync::Arc<dyn domain::events::EventStore> =
std::sync::Arc::new(InMemoryEventStore::new());
let ensemble_classifier = EnsembleClassifier::new(EnsembleConfig::default());
Self {
config,
@@ -304,10 +310,68 @@ impl DisasterResponse {
detection_pipeline,
localization_service,
alert_dispatcher,
event_store,
ensemble_classifier,
running: std::sync::atomic::AtomicBool::new(false),
}
}
/// Create with a custom event store (e.g. for persistence or testing)
pub fn with_event_store(
config: DisasterConfig,
event_store: std::sync::Arc<dyn domain::events::EventStore>,
) -> Self {
let detection_config = DetectionConfig::from_disaster_config(&config);
let detection_pipeline = DetectionPipeline::new(detection_config);
let localization_service = LocalizationService::new();
let alert_dispatcher = AlertDispatcher::new(config.alert_config.clone());
let ensemble_classifier = EnsembleClassifier::new(EnsembleConfig::default());
Self {
config,
event: None,
detection_pipeline,
localization_service,
alert_dispatcher,
event_store,
ensemble_classifier,
running: std::sync::atomic::AtomicBool::new(false),
}
}
/// Push CSI data into the detection pipeline for processing.
///
/// This is the primary data ingestion point. Call this with real CSI
/// amplitude and phase readings from hardware (ESP32, Intel 5300, etc).
/// Returns an error string if data is invalid.
pub fn push_csi_data(&self, amplitudes: &[f64], phases: &[f64]) -> Result<()> {
if amplitudes.len() != phases.len() {
return Err(MatError::Detection(
"Amplitude and phase arrays must have equal length".into(),
));
}
if amplitudes.is_empty() {
return Err(MatError::Detection("CSI data cannot be empty".into()));
}
self.detection_pipeline.add_data(amplitudes, phases);
Ok(())
}
/// Get the event store for querying domain events
pub fn event_store(&self) -> &std::sync::Arc<dyn domain::events::EventStore> {
&self.event_store
}
/// Get the ensemble classifier
pub fn ensemble_classifier(&self) -> &EnsembleClassifier {
&self.ensemble_classifier
}
/// Get the detection pipeline (for direct buffer inspection / data push)
pub fn detection_pipeline(&self) -> &DetectionPipeline {
&self.detection_pipeline
}
/// Initialize a new disaster event
pub fn initialize_event(
&mut self,
@@ -358,8 +422,14 @@ impl DisasterResponse {
self.running.store(false, Ordering::SeqCst);
}
/// Execute a single scan cycle
/// Execute a single scan cycle.
///
/// Processes all active zones, runs detection pipeline on buffered CSI data,
/// applies ensemble classification, emits domain events to the EventStore,
/// and dispatches alerts for newly detected survivors.
async fn scan_cycle(&mut self) -> Result<()> {
let scan_start = std::time::Instant::now();
// Collect detections first to avoid borrowing issues
let mut detections = Vec::new();
@@ -372,17 +442,33 @@ impl DisasterResponse {
continue;
}
// This would integrate with actual hardware in production
// For now, we process any available CSI data
// Process buffered CSI data through the detection pipeline
let detection_result = self.detection_pipeline.process_zone(zone).await?;
if let Some(vital_signs) = detection_result {
// Attempt localization
let location = self.localization_service
.estimate_position(&vital_signs, zone);
// Run ensemble classifier to combine breathing + heartbeat + movement
let ensemble_result = self.ensemble_classifier.classify(&vital_signs);
detections.push((zone.id().clone(), vital_signs, location));
// Only proceed if ensemble confidence meets threshold
if ensemble_result.confidence >= self.config.confidence_threshold {
// Attempt localization
let location = self.localization_service
.estimate_position(&vital_signs, zone);
detections.push((zone.id().clone(), zone.name().to_string(), vital_signs, location, ensemble_result));
}
}
// Emit zone scan completed event
let scan_duration = scan_start.elapsed();
let _ = self.event_store.append(DomainEvent::Zone(
domain::events::ZoneEvent::ZoneScanCompleted {
zone_id: zone.id().clone(),
detections_found: detections.len() as u32,
scan_duration_ms: scan_duration.as_millis() as u64,
timestamp: chrono::Utc::now(),
},
));
}
}
@@ -390,12 +476,37 @@ impl DisasterResponse {
let event = self.event.as_mut()
.ok_or_else(|| MatError::Domain("No active disaster event".into()))?;
for (zone_id, vital_signs, location) in detections {
let survivor = event.record_detection(zone_id, vital_signs, location)?;
for (zone_id, _zone_name, vital_signs, location, _ensemble) in detections {
let survivor = event.record_detection(zone_id.clone(), vital_signs.clone(), location.clone())?;
// Generate alert if needed
// Emit SurvivorDetected domain event
let _ = self.event_store.append(DomainEvent::Detection(
DetectionEvent::SurvivorDetected {
survivor_id: survivor.id().clone(),
zone_id,
vital_signs,
location,
timestamp: chrono::Utc::now(),
},
));
// Generate and dispatch alert if needed
if survivor.should_alert() {
let alert = self.alert_dispatcher.generate_alert(survivor)?;
let alert_id = alert.id().clone();
let priority = alert.priority();
let survivor_id = alert.survivor_id().clone();
// Emit AlertGenerated domain event
let _ = self.event_store.append(DomainEvent::Alert(
AlertEvent::AlertGenerated {
alert_id,
survivor_id,
priority,
timestamp: chrono::Utc::now(),
},
));
self.alert_dispatcher.dispatch(alert).await?;
}
}
@@ -434,8 +545,12 @@ pub mod prelude {
ScanZone, ZoneBounds, TriageStatus,
VitalSignsReading, BreathingPattern, HeartbeatSignature,
Coordinates3D, Alert, Priority,
// Event sourcing
DomainEvent, EventStore, InMemoryEventStore,
DetectionEvent, AlertEvent,
// Detection
DetectionPipeline, VitalSignsDetector,
EnsembleClassifier, EnsembleConfig, EnsembleResult,
// Localization
LocalizationService,
// Alerting