The Degradation Index (DI) is a cumulative damage accumulator that converts anomaly scores into a monotonically increasing health metric. Unlike instantaneous scores, DI captures accumulated wear over time.
Health is not just current state — it’s cumulative history.A motor that experiences 5 minutes of severe vibration has sustained permanent damage that persists even after vibration returns to normal. DI tracks this irreversible degradation.
# Sensitivity constant for Miner's Rule damage accumulation# DI_inc = (effective_severity ** 2) * SENSITIVITY_CONSTANT# At score=1.0 (max fault), DI increases by 0.005 per second# → Full degradation (DI=1.0) takes ~200 seconds of sustained max fault# → Real faults (score ~0.85-0.95) drive 100→0% in ~4-5 minutes (demo-tuned)SENSITIVITY_CONSTANT = 0.005
Why 0.005?
At maximum anomaly (score = 1.0), effective severity = 1.0
Damage rate = 1.02×0.005=0.005 per second
Full degradation: DI=0→1 in 0.0051.0=200 seconds
This is demo-tuned for rapid visualization. In production, this would be calibrated based on asset Mean Time To Failure (MTTF) data.
# Dead-zone floor: batch_scores below this are treated as healthy noise (zero damage).# IsolationForest calibration produces non-zero scores (0.1–0.5) even on healthy data# due to the contamination parameter. Without this floor the DI accumulator# "self-harms" — phantom damage accrues during normal operation.# Scores above HEALTHY_FLOOR are remapped to [0, 1] via:# effective_severity = (score - HEALTHY_FLOOR) / (1 - HEALTHY_FLOOR)HEALTHY_FLOOR = 0.65
Problem it solves:Isolation Forest with contamination=0.05 assigns scores of 0.1–0.5 even to perfectly healthy data (top 5% of healthy distribution are marked as “outliers”).Without the dead-zone, these false-positive scores would accumulate phantom damage:
# WITHOUT dead-zone (broken):score = 0.5 # Healthy data, but above 0damage_rate = 0.5**2 * 0.005 = 0.00125 per secondDI += 0.00125 every second # Motor degrades while healthy!
With the dead-zone:
# WITH dead-zone (correct):score = 0.5 # Below HEALTHY_FLOOReffective_severity = 0.0 # Treated as healthy noisedamage_rate = 0.0 # Zero damageDI unchanged # No phantom degradation
Dead-zone is critical for preventing false degradation.Without it, the system would report 100% → 0% health over ~24 hours even with no faults.
Problem: DI is stored in memory. If the backend restarts, DI would reset to 0.0, erasing all accumulated damage history.Solution: DI is persisted to InfluxDB and hydrated on startup.From system_routes.py (simplified):
# On startup, restore last DI from InfluxDBdef hydrate_di_from_influx(asset_id: str) -> float: query = f''' from(bucket: "sensor_data") |> range(start: -30d) |> filter(fn: (r) => r["_measurement"] == "degradation") |> filter(fn: (r) => r["asset_id"] == "{asset_id}") |> filter(fn: (r) => r["_field"] == "di") |> last() # Get most recent DI value ''' result = query_api.query(query) if result and len(result) > 0 and len(result[0].records) > 0: return result[0].records[0].get_value() return 0.0 # Default to 0 if no history
Workflow:
Startup: Backend reads last() DI from InfluxDB
Runtime: DI updated in-memory every second
Persistence: New DI written to InfluxDB every second
def crossed_thresholds(old_di: float, new_di: float) -> list: """ Return list of DI threshold milestones crossed between old_di and new_di. Useful for emitting warning events. Args: old_di: Previous DI value new_di: New DI value Returns: List of (threshold_value, percent_label) that were newly crossed """ thresholds = [ (DI_THRESHOLD_15, "15%"), (DI_THRESHOLD_30, "30%"), (DI_THRESHOLD_50, "50%"), (DI_THRESHOLD_75, "75%"), ] crossed = [] for thr, label in thresholds: if old_di < thr <= new_di: crossed.append((thr, label)) return crossed