sound-broken / tests /test_comprehensive.py
mitvho09's picture
Upload Space app
edb671a verified
Raw
History Blame Contribute Delete
44.8 kB
"""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)
# =============================================================================
@pytest.mark.skipif(not _have("sample_washer_bearing.wav"),
reason="run assets/generate_samples.py first")
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]}"
@pytest.mark.skipif(not _have("sample_washer_good.wav"),
reason="run assets/generate_samples.py first")
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"