Spaces:
Runtime error
Runtime error
| """Comprehensive test suite for the entire pipeline. | |
| Covers: | |
| - All 12 appliance types | |
| - Edge cases (empty, garbage, short, loud audio) | |
| - Integration tests (features -> rules -> prompt -> validate) | |
| - Rule engine grounding verification | |
| - JSON guard validation | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import tempfile | |
| import numpy as np | |
| import pytest | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) | |
| from audio_analyzer import extract_features, AudioFeatures # noqa: E402 | |
| from fault_rules import rank_candidates, RULES, GENERIC_FALLBACK # noqa: E402 | |
| from feature_prompt import build_diagnosis_prompt, SYSTEM_PROMPT # noqa: E402 | |
| from json_guard import validate, DiagnosisResult # noqa: E402 | |
| ASSETS = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") | |
| def _have(name): | |
| return os.path.exists(os.path.join(ASSETS, name)) | |
| def _write_wav(samples, sr=22050): | |
| """Write samples to a temp WAV file and return the path.""" | |
| import soundfile as sf | |
| path = tempfile.mktemp(suffix=".wav") | |
| sf.write(path, samples, sr) | |
| return path | |
| def _make_tone(freq, duration, sr=22050, amplitude=0.5): | |
| """Generate a simple sine tone.""" | |
| t = np.linspace(0, duration, int(sr * duration), endpoint=False) | |
| return (amplitude * np.sin(2 * np.pi * freq * t)).astype(np.float32) | |
| def _make_clicks(interval_s, count, sr=22050, amplitude=0.5): | |
| """Generate regular clicks at specified intervals. | |
| Each click is a short burst of broadband noise (30ms) with a sharp attack, | |
| making it detectable by onset detection with delta=0.3. | |
| """ | |
| total_samples = int(sr * interval_s * count * 1.5) | |
| samples = np.zeros(total_samples, dtype=np.float32) | |
| click_len = int(0.03 * sr) # 30ms click | |
| for i in range(count): | |
| start = int(i * interval_s * sr) | |
| if start + click_len < total_samples: | |
| # Sharp-attack noise burst with exponential decay | |
| click = amplitude * np.random.randn(click_len).astype(np.float32) | |
| decay = np.exp(-np.linspace(0, 5, click_len)).astype(np.float32) | |
| samples[start:start+click_len] = click * decay | |
| return samples | |
| def _make_noise(duration, sr=22050, amplitude=0.1): | |
| """Generate random noise.""" | |
| return (amplitude * np.random.randn(int(sr * duration))).astype(np.float32) | |
| # ============================================================================= | |
| # SECTION 1: Original smoke tests (kept for backward compatibility) | |
| # ============================================================================= | |
| def test_bearing_sample_detects_pattern(): | |
| f = extract_features(os.path.join(ASSETS, "sample_washer_bearing.wav")) | |
| assert f.has_regular_pattern, "rhythmic clicks should be detected" | |
| cands = rank_candidates(f, "Washing machine") | |
| assert any("bearing" in c.name.lower() for c in cands), \ | |
| f"bearing should be a candidate, got {[c.name for c in cands]}" | |
| def test_good_sample_is_calm(): | |
| f = extract_features(os.path.join(ASSETS, "sample_washer_good.wav")) | |
| assert not f.has_regular_pattern | |
| assert f.anomaly_score < 0.6 | |
| def test_empty_audio_does_not_crash(tmp_path): | |
| import soundfile as sf | |
| p = tmp_path / "silence.wav" | |
| sf.write(p, np.zeros(1600, dtype="float32"), 16000) | |
| f = extract_features(str(p)) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert cands # always returns at least 'Inconclusive' | |
| # ============================================================================= | |
| # SECTION 2: All 12 appliance rule coverage tests | |
| # ============================================================================= | |
| class TestApplianceRules: | |
| """Verify every appliance in APPLIANCES has rules and returns candidates.""" | |
| def test_all_appliances_have_rules(self): | |
| """Every appliance should have its own rule table (not just generic fallback).""" | |
| expected = [ | |
| "Washing machine", "Tumble dryer", "Refrigerator/Freezer", | |
| "Electric fan", "Air conditioner", "Vacuum cleaner", | |
| "Dishwasher", "Microwave", "Electric motor (generic)", | |
| "Car engine", "Bicycle (chain/gears)", "Power drill", | |
| ] | |
| for appliance in expected: | |
| assert appliance in RULES, f"Missing rules for '{appliance}'" | |
| assert len(RULES[appliance]) >= 2, \ | |
| f"'{appliance}' should have at least 2 rules, has {len(RULES[appliance])}" | |
| def test_washing_machine_bearing(self): | |
| """Washer bearing: regular pattern, bright spectrum.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.03, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=180.0, harmonic_ratio=0.65, | |
| onset_rate_per_sec=3.5, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert any("bearing" in c.name.lower() for c in cands) | |
| assert cands[0].urgency in ("HIGH", "CRITICAL") | |
| def test_washing_machine_belt_slip(self): | |
| """Washer belt slip: high freq harmonic tone.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.01, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=2800, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=2200.0, harmonic_ratio=0.72, | |
| onset_rate_per_sec=0.5, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert any("belt" in c.name.lower() for c in cands) | |
| def test_washing_machine_load_imbalance(self): | |
| """Washer load imbalance: high variance, no pattern.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.05, | |
| zero_crossing_rate=0.04, spectral_centroid_hz=600, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=3000, | |
| dominant_frequency_hz=80.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=1.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-15.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert any("imbalance" in c.name.lower() for c in cands) | |
| def test_washing_machine_foreign_object(self): | |
| """Washer foreign object: irregular harsh knocks.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.02, | |
| zero_crossing_rate=0.15, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=2500, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.2, | |
| onset_rate_per_sec=6.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert any("object" in c.name.lower() for c in cands) | |
| def test_electric_fan_blade_imbalance(self): | |
| """Fan blade imbalance: low freq, amplitude modulation.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.015, | |
| zero_crossing_rate=0.05, spectral_centroid_hz=500, | |
| spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, | |
| dominant_frequency_hz=60.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=0.8, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| assert any("imbalance" in c.name.lower() for c in cands) | |
| def test_electric_fan_bearing_failure(self): | |
| """Fan motor bearing: bright, harsh.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.008, | |
| zero_crossing_rate=0.18, spectral_centroid_hz=3000, | |
| spectral_bandwidth_hz=2500, spectral_rolloff_hz=6000, | |
| dominant_frequency_hz=2500.0, harmonic_ratio=0.4, | |
| onset_rate_per_sec=1.2, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-20.0, anomaly_score=0.65, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| assert any("bearing" in c.name.lower() for c in cands) | |
| def test_electric_fan_blade_strike(self): | |
| """Fan blade striking housing: fast regular ticking.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.02, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=1500, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=3500, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=12.0, has_regular_pattern=True, | |
| pattern_interval_ms=50.0, peak_db=-20.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| assert any("strike" in c.name.lower() or "housing" in c.name.lower() | |
| for c in cands) | |
| def test_car_engine_rod_knock(self): | |
| """Car engine rod knock: rhythmic, bright, loud.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-20.0, rms_variance=0.04, | |
| zero_crossing_rate=0.12, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=200.0, harmonic_ratio=0.7, | |
| onset_rate_per_sec=5.0, has_regular_pattern=True, | |
| pattern_interval_ms=80.0, peak_db=-12.0, anomaly_score=0.85, | |
| ) | |
| cands = rank_candidates(f, "Car engine") | |
| assert any("rod" in c.name.lower() or "knock" in c.name.lower() | |
| for c in cands) | |
| assert cands[0].urgency == "CRITICAL" | |
| def test_car_engine_belt_squeal(self): | |
| """Car engine belt squeal: high freq harmonic.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.005, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=2500.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=0.3, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.5, | |
| ) | |
| cands = rank_candidates(f, "Car engine") | |
| assert any("belt" in c.name.lower() or "squeal" in c.name.lower() | |
| for c in cands) | |
| def test_tumble_dryer_roller_wear(self): | |
| """Tumble dryer drum roller wear: rhythmic thump.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.025, | |
| zero_crossing_rate=0.07, spectral_centroid_hz=1200, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=3500, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.55, | |
| onset_rate_per_sec=3.0, has_regular_pattern=True, | |
| pattern_interval_ms=180.0, peak_db=-20.0, anomaly_score=0.65, | |
| ) | |
| cands = rank_candidates(f, "Tumble dryer") | |
| assert any("roller" in c.name.lower() or "drum" in c.name.lower() | |
| for c in cands) | |
| def test_tumble_dryer_belt_slip(self): | |
| """Tumble dryer belt slip: high squeal.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.008, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1000, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=2000.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=0.3, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Tumble dryer") | |
| assert any("belt" in c.name.lower() for c in cands) | |
| def test_tumble_dryer_foreign_object(self): | |
| """Tumble dryer foreign object: irregular rattle.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.015, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=7.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-20.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Tumble dryer") | |
| assert any("object" in c.name.lower() or "coin" in c.name.lower() | |
| for c in cands) | |
| def test_refrigerator_compressor_bearing(self): | |
| """Fridge compressor bearing: fast regular click.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-32.0, rms_variance=0.01, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=3500, | |
| dominant_frequency_hz=60.0, harmonic_ratio=0.7, | |
| onset_rate_per_sec=8.0, has_regular_pattern=True, | |
| pattern_interval_ms=60.0, peak_db=-25.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Refrigerator/Freezer") | |
| assert any("compressor" in c.name.lower() for c in cands) | |
| def test_refrigerator_evaporator_fan(self): | |
| """Fridge evaporator fan: steady drone.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-35.0, rms_variance=0.008, | |
| zero_crossing_rate=0.05, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=500.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=0.5, has_regular_pattern=True, | |
| pattern_interval_ms=100.0, peak_db=-28.0, anomaly_score=0.5, | |
| ) | |
| cands = rank_candidates(f, "Refrigerator/Freezer") | |
| assert any("fan" in c.name.lower() or "evaporator" in c.name.lower() | |
| for c in cands) | |
| def test_refrigerator_condenser_grind(self): | |
| """Fridge condenser fan grind: broadband harsh.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.01, | |
| zero_crossing_rate=0.18, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=3000, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=2.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Refrigerator/Freezer") | |
| assert any("condenser" in c.name.lower() or "grind" in c.name.lower() | |
| for c in cands) | |
| def test_air_conditioner_compressor_failure(self): | |
| """AC compressor failure: CRITICAL, loud, rhythmic.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-18.0, rms_variance=0.05, | |
| zero_crossing_rate=0.15, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=2500, spectral_rolloff_hz=5500, | |
| dominant_frequency_hz=200.0, harmonic_ratio=0.65, | |
| onset_rate_per_sec=6.0, has_regular_pattern=True, | |
| pattern_interval_ms=100.0, peak_db=-10.0, anomaly_score=0.9, | |
| ) | |
| cands = rank_candidates(f, "Air conditioner") | |
| assert any("compressor" in c.name.lower() for c in cands) | |
| assert cands[0].urgency == "CRITICAL" | |
| def test_air_conditioner_fan_blade(self): | |
| """AC fan blade damage: low thwack.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.02, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=800, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=2500, | |
| dominant_frequency_hz=80.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=4.0, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-22.0, anomaly_score=0.5, | |
| ) | |
| cands = rank_candidates(f, "Air conditioner") | |
| assert any("blade" in c.name.lower() or "fan" in c.name.lower() | |
| for c in cands) | |
| def test_air_conditioner_refrigerant_leak(self): | |
| """AC refrigerant leak: bright hiss, no pattern.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.005, | |
| zero_crossing_rate=0.15, spectral_centroid_hz=3500, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=6000, | |
| dominant_frequency_hz=3000.0, harmonic_ratio=0.2, | |
| onset_rate_per_sec=0.2, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-25.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Air conditioner") | |
| assert any("refrigerant" in c.name.lower() or "leak" in c.name.lower() | |
| for c in cands) | |
| def test_vacuum_brush_roll_bearing(self): | |
| """Vacuum brush roll bearing: fast regular click.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-22.0, rms_variance=0.015, | |
| zero_crossing_rate=0.12, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=300.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=10.0, has_regular_pattern=True, | |
| pattern_interval_ms=50.0, peak_db=-15.0, anomaly_score=0.7, | |
| ) | |
| cands = rank_candidates(f, "Vacuum cleaner") | |
| assert any("brush" in c.name.lower() or "roll" in c.name.lower() | |
| for c in cands) | |
| def test_vacuum_motor_whine(self): | |
| """Vacuum motor bearing whine: high harmonic.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.008, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=2500.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=0.3, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Vacuum cleaner") | |
| assert any("whine" in c.name.lower() or "bearing" in c.name.lower() | |
| for c in cands) | |
| def test_vacuum_blockage(self): | |
| """Vacuum airway blockage: loud broadband rush.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-20.0, rms_variance=0.01, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2800, | |
| spectral_bandwidth_hz=2500, spectral_rolloff_hz=5500, | |
| dominant_frequency_hz=200.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=0.5, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-12.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Vacuum cleaner") | |
| assert any("block" in c.name.lower() or "airway" in c.name.lower() | |
| for c in cands) | |
| def test_dishwasher_pump_bearing(self): | |
| """Dishwasher wash pump bearing: rhythmic rattle.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.02, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=120.0, harmonic_ratio=0.55, | |
| onset_rate_per_sec=4.0, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-20.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Dishwasher") | |
| assert any("pump" in c.name.lower() or "bearing" in c.name.lower() | |
| for c in cands) | |
| def test_dishwasher_drain_pump_cavitation(self): | |
| """Dishwasher drain pump: irregular gurgle.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.02, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=3500, spectral_rolloff_hz=5500, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.25, | |
| onset_rate_per_sec=5.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Dishwasher") | |
| assert any("drain" in c.name.lower() or "cavitate" in c.name.lower() | |
| for c in cands) | |
| def test_dishwasher_spray_arm(self): | |
| """Dishwasher spray arm imbalance: slow swish.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-35.0, rms_variance=0.015, | |
| zero_crossing_rate=0.04, spectral_centroid_hz=400, | |
| spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, | |
| dominant_frequency_hz=50.0, harmonic_ratio=0.4, | |
| onset_rate_per_sec=0.5, has_regular_pattern=True, | |
| pattern_interval_ms=500.0, peak_db=-30.0, anomaly_score=0.35, | |
| ) | |
| cands = rank_candidates(f, "Dishwasher") | |
| assert any("spray" in c.name.lower() or "arm" in c.name.lower() | |
| for c in cands) | |
| def test_microwave_turntable_motor(self): | |
| """Microwave turntable motor: low hum.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-35.0, rms_variance=0.005, | |
| zero_crossing_rate=0.03, spectral_centroid_hz=300, | |
| spectral_bandwidth_hz=500, spectral_rolloff_hz=1000, | |
| dominant_frequency_hz=50.0, harmonic_ratio=0.7, | |
| onset_rate_per_sec=0.1, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-30.0, anomaly_score=0.2, | |
| ) | |
| cands = rank_candidates(f, "Microwave") | |
| assert any("turntable" in c.name.lower() or "motor" in c.name.lower() | |
| for c in cands) | |
| def test_microwave_magnetron_arcing(self): | |
| """Microwave magnetron arcing: harsh buzz.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-20.0, rms_variance=0.03, | |
| zero_crossing_rate=0.25, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=3000, spectral_rolloff_hz=6000, | |
| dominant_frequency_hz=2000.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=2.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-12.0, anomaly_score=0.8, | |
| ) | |
| cands = rank_candidates(f, "Microwave") | |
| assert any("magnetron" in c.name.lower() for c in cands) | |
| def test_microwave_cooling_fan(self): | |
| """Microwave cooling fan bearing: fast tick.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.008, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=1500, | |
| spectral_bandwidth_hz=1000, spectral_rolloff_hz=3000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=6.0, has_regular_pattern=True, | |
| pattern_interval_ms=80.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Microwave") | |
| assert any("cooling" in c.name.lower() or "fan" in c.name.lower() | |
| for c in cands) | |
| def test_bicycle_chain_wear(self): | |
| """Bicycle chain wear: fast rhythmic click.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-35.0, rms_variance=0.01, | |
| zero_crossing_rate=0.06, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=1200, spectral_rolloff_hz=3500, | |
| dominant_frequency_hz=150.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=6.0, has_regular_pattern=True, | |
| pattern_interval_ms=80.0, peak_db=-28.0, anomaly_score=0.55, | |
| ) | |
| cands = rank_candidates(f, "Bicycle (chain/gears)") | |
| assert any("chain" in c.name.lower() for c in cands) | |
| def test_bicycle_wheel_bearing(self): | |
| """Bicycle wheel bearing: regular thump.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.02, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.55, | |
| onset_rate_per_sec=3.0, has_regular_pattern=True, | |
| pattern_interval_ms=200.0, peak_db=-24.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Bicycle (chain/gears)") | |
| assert any("bearing" in c.name.lower() or "wheel" in c.name.lower() | |
| for c in cands) | |
| def test_bicycle_derailleur(self): | |
| """Bicycle derailleur misalignment: irregular metallic rattle.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-32.0, rms_variance=0.015, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=4.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-26.0, anomaly_score=0.5, | |
| ) | |
| cands = rank_candidates(f, "Bicycle (chain/gears)") | |
| assert any("derailleur" in c.name.lower() for c in cands) | |
| def test_power_drill_brush_wear(self): | |
| """Power drill brush wear: harsh broadband.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.02, | |
| zero_crossing_rate=0.22, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=3500, spectral_rolloff_hz=6000, | |
| dominant_frequency_hz=1500.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=1.5, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.65, | |
| ) | |
| cands = rank_candidates(f, "Power drill") | |
| assert any("brush" in c.name.lower() or "commutator" in c.name.lower() | |
| for c in cands) | |
| def test_power_drill_gear_grinding(self): | |
| """Power drill gear grinding: bright non-tonal.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-26.0, rms_variance=0.025, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.3, | |
| onset_rate_per_sec=4.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-18.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Power drill") | |
| assert any("gear" in c.name.lower() or "grind" in c.name.lower() | |
| for c in cands) | |
| def test_power_drill_bearing_failure(self): | |
| """Power drill bearing failure: fast regular tick.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-24.0, rms_variance=0.02, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=200.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=8.0, has_regular_pattern=True, | |
| pattern_interval_ms=60.0, peak_db=-16.0, anomaly_score=0.7, | |
| ) | |
| cands = rank_candidates(f, "Power drill") | |
| assert any("bearing" in c.name.lower() for c in cands) | |
| def test_generic_motor_bearing(self): | |
| """Generic motor bearing failure.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-26.0, rms_variance=0.02, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=1500, spectral_rolloff_hz=4000, | |
| dominant_frequency_hz=150.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=3.0, has_regular_pattern=True, | |
| pattern_interval_ms=120.0, peak_db=-18.0, anomaly_score=0.7, | |
| ) | |
| cands = rank_candidates(f, "Electric motor (generic)") | |
| assert any("bearing" in c.name.lower() for c in cands) | |
| def test_generic_motor_hum(self): | |
| """Generic motor electrical hum.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-35.0, rms_variance=0.005, | |
| zero_crossing_rate=0.03, spectral_centroid_hz=400, | |
| spectral_bandwidth_hz=500, spectral_rolloff_hz=1200, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.7, | |
| onset_rate_per_sec=0.1, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-30.0, anomaly_score=0.2, | |
| ) | |
| cands = rank_candidates(f, "Electric motor (generic)") | |
| assert any("hum" in c.name.lower() or "lamination" in c.name.lower() | |
| for c in cands) | |
| def test_generic_motor_brush_arcing(self): | |
| """Generic motor brush arcing.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.015, | |
| zero_crossing_rate=0.22, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=3500, spectral_rolloff_hz=6000, | |
| dominant_frequency_hz=100.0, harmonic_ratio=0.2, | |
| onset_rate_per_sec=1.5, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-22.0, anomaly_score=0.6, | |
| ) | |
| cands = rank_candidates(f, "Electric motor (generic)") | |
| assert any("brush" in c.name.lower() or "arcing" in c.name.lower() | |
| for c in cands) | |
| def test_generic_motor_squeal(self): | |
| """Generic motor high-freq squeal.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.005, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2500, | |
| spectral_bandwidth_hz=1000, spectral_rolloff_hz=5000, | |
| dominant_frequency_hz=2200.0, harmonic_ratio=0.6, | |
| onset_rate_per_sec=0.3, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.5, | |
| ) | |
| cands = rank_candidates(f, "Electric motor (generic)") | |
| assert any("squeal" in c.name.lower() or "whine" in c.name.lower() | |
| for c in cands) | |
| # ============================================================================= | |
| # SECTION 3: Edge case tests | |
| # ============================================================================= | |
| class TestEdgeCases: | |
| """Test defensive behavior on degenerate inputs.""" | |
| def test_silence_audio(self): | |
| """Pure silence should return Inconclusive.""" | |
| path = _write_wav(np.zeros(22050, dtype="float32")) # 1s silence | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Washing machine") | |
| assert len(cands) >= 1 | |
| assert cands[0].name == "Inconclusive" | |
| finally: | |
| os.unlink(path) | |
| def test_garbage_audio(self): | |
| """Random noise should not crash and should return candidates.""" | |
| path = _write_wav(np.random.randn(22050 * 3).astype(np.float32) * 0.01) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Electric fan") | |
| assert len(cands) >= 1 | |
| finally: | |
| os.unlink(path) | |
| def test_very_short_audio(self): | |
| """50ms audio should not crash.""" | |
| samples = np.random.randn(1102).astype(np.float32) * 0.1 | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Microwave") | |
| assert len(cands) >= 1 | |
| finally: | |
| os.unlink(path) | |
| def test_very_loud_audio(self): | |
| """Clipping audio should not crash.""" | |
| samples = np.ones(22050, dtype="float32") * 0.99 | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Power drill") | |
| assert len(cands) >= 1 | |
| assert f.peak_db > -1.0 # should be very loud | |
| finally: | |
| os.unlink(path) | |
| def test_very_quiet_audio(self): | |
| """Near-silence audio should return Inconclusive.""" | |
| samples = np.random.randn(22050).astype(np.float32) * 0.0001 | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Dishwasher") | |
| assert len(cands) >= 1 | |
| finally: | |
| os.unlink(path) | |
| def test_all_zeros(self): | |
| """Completely zeroed audio should not crash.""" | |
| path = _write_wav(np.zeros(44100, dtype="float32")) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Air conditioner") | |
| assert len(cands) >= 1 | |
| assert cands[0].name == "Inconclusive" | |
| finally: | |
| os.unlink(path) | |
| def test_very_long_audio(self): | |
| """30s audio should work (will be truncated to 10s by analyzer).""" | |
| samples = np.random.randn(22050 * 30).astype(np.float32) * 0.1 | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Vacuum cleaner") | |
| assert len(cands) >= 1 | |
| assert f.duration_s <= 10.1 # truncated to ~10s | |
| finally: | |
| os.unlink(path) | |
| def test_high_frequency_squeal(self): | |
| """Pure 4kHz tone should trigger tonal rules.""" | |
| samples = _make_tone(4000, 3.0, amplitude=0.5) | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| assert f.spectral_centroid_hz > 3000 | |
| assert f.harmonic_ratio > 0.5 | |
| finally: | |
| os.unlink(path) | |
| def test_low_frequency_rumble(self): | |
| """Pure 40Hz tone should trigger rumble rules.""" | |
| samples = _make_tone(40, 3.0, amplitude=0.5) | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| assert f.spectral_centroid_hz < 200 | |
| finally: | |
| os.unlink(path) | |
| def test_regular_click_pattern(self): | |
| """Regular amplitude-modulated clicks at 150ms should be detected as pattern. | |
| We generate a continuous tone with periodic amplitude dips — this is | |
| closer to what real onset detection can lock onto (the amplitude envelope | |
| modulation rather than isolated noise bursts). | |
| """ | |
| sr = 22050 | |
| interval_ms = 150 | |
| interval_s = interval_ms / 1000.0 | |
| duration = 4.0 | |
| n_samples = int(sr * duration) | |
| t = np.linspace(0, duration, n_samples, endpoint=False) | |
| # Base tone at 200 Hz | |
| signal = 0.3 * np.sin(2 * np.pi * 200 * t).astype(np.float32) | |
| # Add periodic amplitude modulation (clicks) at 150ms intervals | |
| click_envelope = np.ones(n_samples, dtype=np.float32) | |
| click_len = int(0.02 * sr) # 20ms dip | |
| n_clicks = int(duration / interval_s) | |
| for i in range(n_clicks): | |
| pos = int(i * interval_s * sr) | |
| if pos + click_len < n_samples: | |
| # Sharp amplitude dip (onset detector sees these as events) | |
| click_envelope[pos:pos+click_len] = 0.05 | |
| signal *= click_envelope | |
| # Add a small noise floor | |
| signal += 0.005 * np.random.randn(n_samples).astype(np.float32) | |
| path = _write_wav(signal, sr) | |
| try: | |
| f = extract_features(path) | |
| # The onset detector should find regular events | |
| assert f.onset_rate_per_sec > 2.0, ( | |
| f"should detect onsets, got rate={f.onset_rate_per_sec}" | |
| ) | |
| finally: | |
| os.unlink(path) | |
| # ============================================================================= | |
| # SECTION 4: Integration tests (full pipeline) | |
| # ============================================================================= | |
| class TestIntegration: | |
| """Test the full pipeline: audio -> features -> rules -> prompt -> validate.""" | |
| def test_full_pipeline_bearing(self): | |
| """Bearing sample should produce grounded diagnosis.""" | |
| samples = _make_clicks(0.15, 15, amplitude=0.5) # 150ms intervals | |
| path = _write_wav(samples) | |
| try: | |
| f = extract_features(path) | |
| cands = rank_candidates(f, "Washing machine") | |
| prompt = build_diagnosis_prompt(f, cands, "Washing machine") | |
| # Mock response | |
| response = json.dumps({ | |
| "fault": cands[0].name, | |
| "urgency": cands[0].urgency, | |
| "checks": ["Inspect the bearing.", "Listen again.", "Call tech."], | |
| "safety": "Disconnect power.", | |
| "confidence": 85, | |
| }) | |
| result = validate(response, cands) | |
| assert isinstance(result, DiagnosisResult) | |
| assert result.grounded | |
| assert result.confidence > 0 | |
| assert len(result.checks) > 0 | |
| finally: | |
| os.unlink(path) | |
| def test_full_pipeline_ungrounded_output(self): | |
| """Model output naming a non-candidate should be snapped back.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.03, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=180.0, harmonic_ratio=0.65, | |
| onset_rate_per_sec=3.5, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| # Model returns a fault NOT in candidates | |
| response = json.dumps({ | |
| "fault": "Exploding capacitor", | |
| "urgency": "CRITICAL", | |
| "checks": ["Check capacitor."], | |
| "safety": "Unplug.", | |
| "confidence": 95, | |
| }) | |
| result = validate(response, cands) | |
| assert result.grounded # should be snapped to a candidate | |
| assert result.fault != "Exploding capacitor" | |
| def test_full_pipeline_malformed_json(self): | |
| """Malformed JSON should fall back to top candidate.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.01, | |
| zero_crossing_rate=0.05, spectral_centroid_hz=500, | |
| spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, | |
| dominant_frequency_hz=60.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=0.8, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| result = validate("This is not JSON at all!", cands) | |
| assert result.grounded | |
| assert result.fault == cands[0].name | |
| def test_full_pipeline_empty_response(self): | |
| """Empty model response should fall back gracefully.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-28.0, rms_variance=0.04, | |
| zero_crossing_rate=0.12, spectral_centroid_hz=1800, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=200.0, harmonic_ratio=0.7, | |
| onset_rate_per_sec=5.0, has_regular_pattern=True, | |
| pattern_interval_ms=80.0, peak_db=-12.0, anomaly_score=0.85, | |
| ) | |
| cands = rank_candidates(f, "Car engine") | |
| result = validate("", cands) | |
| assert result.grounded | |
| assert result.fault == cands[0].name | |
| def test_prompt_contains_all_features(self): | |
| """Prompt should contain all measured feature values.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.03, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=180.0, harmonic_ratio=0.65, | |
| onset_rate_per_sec=3.5, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| prompt = build_diagnosis_prompt(f, cands, "Washing machine") | |
| assert "Washing machine" in prompt | |
| assert "2200" in prompt # spectral centroid | |
| assert "180" in prompt # dominant freq | |
| assert "0.65" in prompt # harmonic ratio | |
| assert "150" in prompt # pattern interval | |
| def test_validate_urgency_bounds(self): | |
| """Confidence should always be 0-100.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.01, | |
| zero_crossing_rate=0.05, spectral_centroid_hz=500, | |
| spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, | |
| dominant_frequency_hz=60.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=0.8, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| # Test with extreme confidence values | |
| for conf in [-50, 0, 50, 100, 150, 999]: | |
| response = json.dumps({ | |
| "fault": cands[0].name, | |
| "urgency": "HIGH", | |
| "checks": ["Check it."], | |
| "safety": "None", | |
| "confidence": conf, | |
| }) | |
| result = validate(response, cands) | |
| assert 0 <= result.confidence <= 100 | |
| def test_validate_invalid_urgency(self): | |
| """Invalid urgency should fall back to candidate urgency.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.03, | |
| zero_crossing_rate=0.08, spectral_centroid_hz=2200, | |
| spectral_bandwidth_hz=1800, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=180.0, harmonic_ratio=0.65, | |
| onset_rate_per_sec=3.5, has_regular_pattern=True, | |
| pattern_interval_ms=150.0, peak_db=-18.0, anomaly_score=0.75, | |
| ) | |
| cands = rank_candidates(f, "Washing machine") | |
| response = json.dumps({ | |
| "fault": cands[0].name, | |
| "urgency": "SUPER_CRITICAL_URGENT", | |
| "checks": ["Check it."], | |
| "safety": "None", | |
| "confidence": 85, | |
| }) | |
| result = validate(response, cands) | |
| assert result.urgency in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN") | |
| def test_validate_empty_checks(self): | |
| """Empty checks list should get default checks.""" | |
| f = AudioFeatures( | |
| duration_s=8.0, rms_db=-30.0, rms_variance=0.01, | |
| zero_crossing_rate=0.05, spectral_centroid_hz=500, | |
| spectral_bandwidth_hz=800, spectral_rolloff_hz=1500, | |
| dominant_frequency_hz=60.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=0.8, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-24.0, anomaly_score=0.45, | |
| ) | |
| cands = rank_candidates(f, "Electric fan") | |
| response = json.dumps({ | |
| "fault": cands[0].name, | |
| "urgency": "HIGH", | |
| "checks": [], | |
| "safety": "None", | |
| "confidence": 80, | |
| }) | |
| result = validate(response, cands) | |
| assert len(result.checks) >= 1 | |
| def test_candidates_always_returned(self): | |
| """rank_candidates should always return at least one candidate.""" | |
| # Test with extreme feature values | |
| extreme_features = [ | |
| AudioFeatures( | |
| duration_s=0.0, rms_db=-80.0, rms_variance=0.0, | |
| zero_crossing_rate=0.0, spectral_centroid_hz=0.0, | |
| spectral_bandwidth_hz=0.0, spectral_rolloff_hz=0.0, | |
| dominant_frequency_hz=0.0, harmonic_ratio=0.0, | |
| onset_rate_per_sec=0.0, has_regular_pattern=False, | |
| pattern_interval_ms=0.0, peak_db=-80.0, anomaly_score=0.0, | |
| ), | |
| AudioFeatures( | |
| duration_s=10.0, rms_db=0.0, rms_variance=1.0, | |
| zero_crossing_rate=1.0, spectral_centroid_hz=10000.0, | |
| spectral_bandwidth_hz=10000.0, spectral_rolloff_hz=11000.0, | |
| dominant_frequency_hz=5000.0, harmonic_ratio=1.0, | |
| onset_rate_per_sec=100.0, has_regular_pattern=True, | |
| pattern_interval_ms=1.0, peak_db=0.0, anomaly_score=1.0, | |
| ), | |
| ] | |
| for f in extreme_features: | |
| for appliance in RULES.keys(): | |
| cands = rank_candidates(f, appliance) | |
| assert len(cands) >= 1, f"No candidates for {appliance}" | |
| def test_all_rules_fires_at_least_one(self): | |
| """Each rule table should have at least one rule that fires for a typical input.""" | |
| # Create a "typical bad" feature set for each appliance | |
| typical_bad = AudioFeatures( | |
| duration_s=8.0, rms_db=-25.0, rms_variance=0.03, | |
| zero_crossing_rate=0.1, spectral_centroid_hz=2000, | |
| spectral_bandwidth_hz=2000, spectral_rolloff_hz=4500, | |
| dominant_frequency_hz=150.0, harmonic_ratio=0.5, | |
| onset_rate_per_sec=3.0, has_regular_pattern=True, | |
| pattern_interval_ms=120.0, peak_db=-18.0, anomaly_score=0.7, | |
| ) | |
| for appliance in RULES.keys(): | |
| cands = rank_candidates(typical_bad, appliance) | |
| assert len(cands) >= 1, \ | |
| f"No rules fired for {appliance} with typical bad input" | |