1017 lines
38 KiB
Python
1017 lines
38 KiB
Python
|
|
"""AI运维智能体服务 - 设备健康评分、异常检测、诊断智能、预测性维护、运营洞察
|
|||
|
|
|
|||
|
|
Inspired by Envision's "构网智能体" concept: self-sensing, self-adapting, self-evolving
|
|||
|
|
intelligent agents for energy asset management.
|
|||
|
|
"""
|
|||
|
|
import logging
|
|||
|
|
import random
|
|||
|
|
import math
|
|||
|
|
from datetime import datetime, timezone, timedelta
|
|||
|
|
from sqlalchemy import select, func, and_, desc
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
from app.models.device import Device
|
|||
|
|
from app.models.energy import EnergyData, EnergyDailySummary
|
|||
|
|
from app.models.alarm import AlarmEvent
|
|||
|
|
from app.models.ai_ops import (
|
|||
|
|
DeviceHealthScore, AnomalyDetection, DiagnosticReport,
|
|||
|
|
MaintenancePrediction, OpsInsight,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("ai_ops")
|
|||
|
|
|
|||
|
|
# ── Device type configurations ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
DEVICE_RATED_EFFICIENCY = {
|
|||
|
|
"pv_inverter": {"metric": "power", "rated_cop": None, "temp_range": (20, 60)},
|
|||
|
|
"heat_pump": {"metric": "cop", "rated_cop": 3.5, "temp_range": (30, 55)},
|
|||
|
|
"meter": {"metric": "power_factor", "rated_cop": None, "temp_range": None},
|
|||
|
|
"sensor": {"metric": "temperature", "rated_cop": None, "temp_range": (18, 28)},
|
|||
|
|
"heat_meter": {"metric": "heat_power", "rated_cop": None, "temp_range": None},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HEALTH_WEIGHTS = {
|
|||
|
|
"power_stability": 0.20,
|
|||
|
|
"efficiency": 0.25,
|
|||
|
|
"alarm_frequency": 0.20,
|
|||
|
|
"uptime": 0.20,
|
|||
|
|
"temperature": 0.15,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Health Score Calculation ────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def calculate_device_health(
|
|||
|
|
session: AsyncSession, device: Device, now: datetime | None = None
|
|||
|
|
) -> DeviceHealthScore:
|
|||
|
|
"""Calculate health score (0-100) for a single device based on weighted factors."""
|
|||
|
|
now = now or datetime.now(timezone.utc)
|
|||
|
|
factors = {}
|
|||
|
|
|
|||
|
|
# Factor 1: Power output stability (std_dev of power over last 24h)
|
|||
|
|
factors["power_stability"] = await _calc_power_stability(session, device.id, now)
|
|||
|
|
|
|||
|
|
# Factor 2: Efficiency / COP
|
|||
|
|
factors["efficiency"] = await _calc_efficiency_score(session, device, now)
|
|||
|
|
|
|||
|
|
# Factor 3: Alarm frequency (last 7 days)
|
|||
|
|
factors["alarm_frequency"] = await _calc_alarm_frequency_score(session, device.id, now)
|
|||
|
|
|
|||
|
|
# Factor 4: Uptime (last 30 days)
|
|||
|
|
factors["uptime"] = await _calc_uptime_score(session, device, now)
|
|||
|
|
|
|||
|
|
# Factor 5: Temperature
|
|||
|
|
factors["temperature"] = await _calc_temperature_score(session, device, now)
|
|||
|
|
|
|||
|
|
# Weighted average
|
|||
|
|
health_score = sum(
|
|||
|
|
factors[k] * HEALTH_WEIGHTS[k] for k in HEALTH_WEIGHTS
|
|||
|
|
)
|
|||
|
|
health_score = max(0, min(100, round(health_score, 1)))
|
|||
|
|
|
|||
|
|
# Status
|
|||
|
|
if health_score >= 80:
|
|||
|
|
status = "healthy"
|
|||
|
|
elif health_score >= 60:
|
|||
|
|
status = "warning"
|
|||
|
|
else:
|
|||
|
|
status = "critical"
|
|||
|
|
|
|||
|
|
# Trend: compare with last score
|
|||
|
|
trend = await _calc_trend(session, device.id, health_score)
|
|||
|
|
|
|||
|
|
score = DeviceHealthScore(
|
|||
|
|
device_id=device.id,
|
|||
|
|
timestamp=now,
|
|||
|
|
health_score=health_score,
|
|||
|
|
status=status,
|
|||
|
|
factors=factors,
|
|||
|
|
trend=trend,
|
|||
|
|
)
|
|||
|
|
session.add(score)
|
|||
|
|
return score
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_power_stability(session: AsyncSession, device_id: int, now: datetime) -> float:
|
|||
|
|
"""Power stability score: low std_dev = high score."""
|
|||
|
|
cutoff = now - timedelta(hours=24)
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(
|
|||
|
|
func.avg(EnergyData.value).label("avg_val"),
|
|||
|
|
func.stddev(EnergyData.value).label("std_val"),
|
|||
|
|
).where(and_(
|
|||
|
|
EnergyData.device_id == device_id,
|
|||
|
|
EnergyData.data_type == "power",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
row = result.one_or_none()
|
|||
|
|
if not row or row.avg_val is None or row.avg_val == 0:
|
|||
|
|
return 85.0 # no data = assume OK
|
|||
|
|
avg_val = float(row.avg_val)
|
|||
|
|
std_val = float(row.std_val or 0)
|
|||
|
|
cv = std_val / avg_val if avg_val > 0 else 0 # coefficient of variation
|
|||
|
|
# cv < 0.1 = 100, cv > 0.5 = 40
|
|||
|
|
score = max(40, min(100, 100 - (cv - 0.1) * 150))
|
|||
|
|
return round(score, 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_efficiency_score(session: AsyncSession, device: Device, now: datetime) -> float:
|
|||
|
|
"""Efficiency score based on device type."""
|
|||
|
|
cutoff = now - timedelta(hours=24)
|
|||
|
|
if device.device_type == "heat_pump":
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.avg(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "cop",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
avg_cop = result.scalar()
|
|||
|
|
if avg_cop is None:
|
|||
|
|
return 85.0
|
|||
|
|
rated_cop = 3.5
|
|||
|
|
ratio = float(avg_cop) / rated_cop
|
|||
|
|
return round(max(30, min(100, ratio * 100)), 1)
|
|||
|
|
elif device.device_type == "pv_inverter":
|
|||
|
|
rated_power = device.rated_power or 110.0
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.max(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "power",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
max_power = result.scalar()
|
|||
|
|
if max_power is None:
|
|||
|
|
return 85.0
|
|||
|
|
ratio = float(max_power) / rated_power
|
|||
|
|
return round(max(30, min(100, ratio * 110)), 1)
|
|||
|
|
elif device.device_type == "meter":
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.avg(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "power_factor",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
avg_pf = result.scalar()
|
|||
|
|
if avg_pf is None:
|
|||
|
|
return 85.0
|
|||
|
|
return round(max(40, min(100, float(avg_pf) * 105)), 1)
|
|||
|
|
return 85.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_alarm_frequency_score(session: AsyncSession, device_id: int, now: datetime) -> float:
|
|||
|
|
"""Fewer alarms = higher score. 0 alarms = 100, 10+ = 30."""
|
|||
|
|
cutoff = now - timedelta(days=7)
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.count(AlarmEvent.id)).where(and_(
|
|||
|
|
AlarmEvent.device_id == device_id,
|
|||
|
|
AlarmEvent.triggered_at >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
count = result.scalar() or 0
|
|||
|
|
score = max(30, 100 - count * 7)
|
|||
|
|
return float(score)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_uptime_score(session: AsyncSession, device: Device, now: datetime) -> float:
|
|||
|
|
"""Uptime based on device status and data availability."""
|
|||
|
|
cutoff = now - timedelta(days=7)
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.count(EnergyData.id)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "power",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
data_count = result.scalar() or 0
|
|||
|
|
# Expected ~4 readings/min * 60 * 24 * 7 = ~40320 (at 15s interval)
|
|||
|
|
expected = 7 * 24 * 60 * 4
|
|||
|
|
ratio = min(1.0, data_count / max(1, expected))
|
|||
|
|
# Also check current status
|
|||
|
|
status_penalty = 0
|
|||
|
|
if device.status == "offline":
|
|||
|
|
status_penalty = 15
|
|||
|
|
elif device.status == "alarm":
|
|||
|
|
status_penalty = 5
|
|||
|
|
return round(max(30, ratio * 100 - status_penalty), 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_temperature_score(session: AsyncSession, device: Device, now: datetime) -> float:
|
|||
|
|
"""Temperature within normal range = high score."""
|
|||
|
|
cfg = DEVICE_RATED_EFFICIENCY.get(device.device_type, {})
|
|||
|
|
temp_range = cfg.get("temp_range")
|
|||
|
|
if not temp_range:
|
|||
|
|
return 90.0 # N/A for this device type
|
|||
|
|
|
|||
|
|
cutoff = now - timedelta(hours=6)
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.avg(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "temperature",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
avg_temp = result.scalar()
|
|||
|
|
if avg_temp is None:
|
|||
|
|
return 85.0
|
|||
|
|
avg_temp = float(avg_temp)
|
|||
|
|
low, high = temp_range
|
|||
|
|
if low <= avg_temp <= high:
|
|||
|
|
return 100.0
|
|||
|
|
deviation = max(0, avg_temp - high, low - avg_temp)
|
|||
|
|
return round(max(20, 100 - deviation * 5), 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _calc_trend(session: AsyncSession, device_id: int, current_score: float) -> str:
|
|||
|
|
"""Compare with recent scores to determine trend."""
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(DeviceHealthScore.health_score)
|
|||
|
|
.where(DeviceHealthScore.device_id == device_id)
|
|||
|
|
.order_by(DeviceHealthScore.timestamp.desc())
|
|||
|
|
.limit(5)
|
|||
|
|
)
|
|||
|
|
scores = [float(r) for r in result.scalars().all()]
|
|||
|
|
if len(scores) < 2:
|
|||
|
|
return "stable"
|
|||
|
|
avg_prev = sum(scores) / len(scores)
|
|||
|
|
diff = current_score - avg_prev
|
|||
|
|
if diff > 3:
|
|||
|
|
return "improving"
|
|||
|
|
elif diff < -3:
|
|||
|
|
return "degrading"
|
|||
|
|
return "stable"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Anomaly Detection ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def scan_anomalies(session: AsyncSession, device_id: int | None = None) -> list[AnomalyDetection]:
|
|||
|
|
"""Scan for anomalies across devices using Z-score and pattern-based methods."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
anomalies = []
|
|||
|
|
|
|||
|
|
query = select(Device).where(Device.is_active == True)
|
|||
|
|
if device_id:
|
|||
|
|
query = query.where(Device.id == device_id)
|
|||
|
|
result = await session.execute(query)
|
|||
|
|
devices = result.scalars().all()
|
|||
|
|
|
|||
|
|
for device in devices:
|
|||
|
|
# Z-score based detection
|
|||
|
|
device_anomalies = await _zscore_detection(session, device, now)
|
|||
|
|
anomalies.extend(device_anomalies)
|
|||
|
|
|
|||
|
|
# Pattern-based detection
|
|||
|
|
pattern_anomalies = await _pattern_detection(session, device, now)
|
|||
|
|
anomalies.extend(pattern_anomalies)
|
|||
|
|
|
|||
|
|
for a in anomalies:
|
|||
|
|
session.add(a)
|
|||
|
|
|
|||
|
|
return anomalies
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _zscore_detection(
|
|||
|
|
session: AsyncSession, device: Device, now: datetime
|
|||
|
|
) -> list[AnomalyDetection]:
|
|||
|
|
"""Detect anomalies using Z-score method (> 3 sigma)."""
|
|||
|
|
anomalies = []
|
|||
|
|
metrics = ["power"]
|
|||
|
|
if device.device_type == "heat_pump":
|
|||
|
|
metrics.append("cop")
|
|||
|
|
if device.device_type == "sensor":
|
|||
|
|
metrics = ["temperature"]
|
|||
|
|
|
|||
|
|
for metric in metrics:
|
|||
|
|
# Get rolling stats from last 24h
|
|||
|
|
cutoff_stats = now - timedelta(hours=24)
|
|||
|
|
stats_result = await session.execute(
|
|||
|
|
select(
|
|||
|
|
func.avg(EnergyData.value).label("avg_val"),
|
|||
|
|
func.stddev(EnergyData.value).label("std_val"),
|
|||
|
|
).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == metric,
|
|||
|
|
EnergyData.timestamp >= cutoff_stats,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
stats = stats_result.one_or_none()
|
|||
|
|
if not stats or stats.avg_val is None or stats.std_val is None or float(stats.std_val) == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
avg_val = float(stats.avg_val)
|
|||
|
|
std_val = float(stats.std_val)
|
|||
|
|
|
|||
|
|
# Check latest value
|
|||
|
|
latest_result = await session.execute(
|
|||
|
|
select(EnergyData.value).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == metric,
|
|||
|
|
)).order_by(EnergyData.timestamp.desc()).limit(1)
|
|||
|
|
)
|
|||
|
|
latest = latest_result.scalar()
|
|||
|
|
if latest is None:
|
|||
|
|
continue
|
|||
|
|
latest = float(latest)
|
|||
|
|
|
|||
|
|
z_score = abs(latest - avg_val) / std_val
|
|||
|
|
if z_score > 3:
|
|||
|
|
deviation_pct = round(abs(latest - avg_val) / avg_val * 100, 1) if avg_val != 0 else 0
|
|||
|
|
severity = "critical" if z_score > 5 else "warning"
|
|||
|
|
anomaly_type = _classify_anomaly(device.device_type, metric, latest, avg_val)
|
|||
|
|
|
|||
|
|
anomalies.append(AnomalyDetection(
|
|||
|
|
device_id=device.id,
|
|||
|
|
detected_at=now,
|
|||
|
|
anomaly_type=anomaly_type,
|
|||
|
|
severity=severity,
|
|||
|
|
description=f"{metric} 异常: 当前值 {latest:.2f}, 均值 {avg_val:.2f}, Z-score {z_score:.1f}",
|
|||
|
|
metric_name=metric,
|
|||
|
|
expected_value=round(avg_val, 2),
|
|||
|
|
actual_value=round(latest, 2),
|
|||
|
|
deviation_percent=deviation_pct,
|
|||
|
|
status="detected",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return anomalies
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _classify_anomaly(device_type: str, metric: str, actual: float, expected: float) -> str:
|
|||
|
|
"""Classify anomaly type based on device type and metric."""
|
|||
|
|
if metric == "power" and actual < expected:
|
|||
|
|
return "power_drop"
|
|||
|
|
if metric == "cop" and actual < expected:
|
|||
|
|
return "efficiency_loss"
|
|||
|
|
if metric == "temperature":
|
|||
|
|
return "abnormal_temperature"
|
|||
|
|
return "pattern_deviation"
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _pattern_detection(
|
|||
|
|
session: AsyncSession, device: Device, now: datetime
|
|||
|
|
) -> list[AnomalyDetection]:
|
|||
|
|
"""Pattern-based anomaly detection for specific device types."""
|
|||
|
|
anomalies = []
|
|||
|
|
|
|||
|
|
# Check for communication loss (no data in last 5 minutes)
|
|||
|
|
cutoff = now - timedelta(minutes=5)
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(func.count(EnergyData.id)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
count = result.scalar() or 0
|
|||
|
|
if count == 0 and device.status == "online":
|
|||
|
|
anomalies.append(AnomalyDetection(
|
|||
|
|
device_id=device.id,
|
|||
|
|
detected_at=now,
|
|||
|
|
anomaly_type="communication_loss",
|
|||
|
|
severity="warning",
|
|||
|
|
description=f"设备 {device.name} 超过5分钟无数据上报",
|
|||
|
|
metric_name="data_availability",
|
|||
|
|
expected_value=1.0,
|
|||
|
|
actual_value=0.0,
|
|||
|
|
deviation_percent=100.0,
|
|||
|
|
status="detected",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# PV specific: power drop during daytime (8:00-17:00 Beijing time)
|
|||
|
|
if device.device_type == "pv_inverter":
|
|||
|
|
beijing_hour = (now + timedelta(hours=8)).hour
|
|||
|
|
if 8 <= beijing_hour <= 17:
|
|||
|
|
latest_result = await session.execute(
|
|||
|
|
select(EnergyData.value).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "power",
|
|||
|
|
)).order_by(EnergyData.timestamp.desc()).limit(1)
|
|||
|
|
)
|
|||
|
|
power = latest_result.scalar()
|
|||
|
|
rated = device.rated_power or 110.0
|
|||
|
|
if power is not None and float(power) < rated * 0.05 and beijing_hour >= 9 and beijing_hour <= 16:
|
|||
|
|
anomalies.append(AnomalyDetection(
|
|||
|
|
device_id=device.id,
|
|||
|
|
detected_at=now,
|
|||
|
|
anomaly_type="power_drop",
|
|||
|
|
severity="warning",
|
|||
|
|
description=f"光伏 {device.name} 在日照时段功率异常偏低: {power:.1f} kW",
|
|||
|
|
metric_name="power",
|
|||
|
|
expected_value=round(rated * 0.3, 2),
|
|||
|
|
actual_value=round(float(power), 2),
|
|||
|
|
deviation_percent=round((1 - float(power) / (rated * 0.3)) * 100, 1),
|
|||
|
|
status="detected",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Heat pump: COP degradation
|
|||
|
|
if device.device_type == "heat_pump":
|
|||
|
|
latest_result = await session.execute(
|
|||
|
|
select(EnergyData.value).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "cop",
|
|||
|
|
)).order_by(EnergyData.timestamp.desc()).limit(1)
|
|||
|
|
)
|
|||
|
|
cop = latest_result.scalar()
|
|||
|
|
if cop is not None and float(cop) < 2.0:
|
|||
|
|
anomalies.append(AnomalyDetection(
|
|||
|
|
device_id=device.id,
|
|||
|
|
detected_at=now,
|
|||
|
|
anomaly_type="efficiency_loss",
|
|||
|
|
severity="warning" if float(cop) >= 1.5 else "critical",
|
|||
|
|
description=f"热泵 {device.name} COP降至 {cop:.2f},低于正常水平",
|
|||
|
|
metric_name="cop",
|
|||
|
|
expected_value=3.5,
|
|||
|
|
actual_value=round(float(cop), 2),
|
|||
|
|
deviation_percent=round((1 - float(cop) / 3.5) * 100, 1),
|
|||
|
|
status="detected",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return anomalies
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Diagnostic Intelligence ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def run_diagnostics(
|
|||
|
|
session: AsyncSession, device_id: int, report_type: str = "triggered"
|
|||
|
|
) -> DiagnosticReport:
|
|||
|
|
"""Run diagnostic analysis for a device and generate report."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
|
|||
|
|
result = await session.execute(select(Device).where(Device.id == device_id))
|
|||
|
|
device = result.scalar_one_or_none()
|
|||
|
|
if not device:
|
|||
|
|
raise ValueError(f"Device {device_id} not found")
|
|||
|
|
|
|||
|
|
findings = []
|
|||
|
|
recommendations = []
|
|||
|
|
energy_loss_kwh = 0.0
|
|||
|
|
cost_impact_yuan = 0.0
|
|||
|
|
|
|||
|
|
# Check recent anomalies
|
|||
|
|
cutoff = now - timedelta(days=7)
|
|||
|
|
anomaly_result = await session.execute(
|
|||
|
|
select(AnomalyDetection).where(and_(
|
|||
|
|
AnomalyDetection.device_id == device_id,
|
|||
|
|
AnomalyDetection.detected_at >= cutoff,
|
|||
|
|
)).order_by(AnomalyDetection.detected_at.desc())
|
|||
|
|
)
|
|||
|
|
anomalies = anomaly_result.scalars().all()
|
|||
|
|
|
|||
|
|
# Check alarm history
|
|||
|
|
alarm_result = await session.execute(
|
|||
|
|
select(AlarmEvent).where(and_(
|
|||
|
|
AlarmEvent.device_id == device_id,
|
|||
|
|
AlarmEvent.triggered_at >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
alarms = alarm_result.scalars().all()
|
|||
|
|
|
|||
|
|
# Get current health
|
|||
|
|
health_result = await session.execute(
|
|||
|
|
select(DeviceHealthScore).where(
|
|||
|
|
DeviceHealthScore.device_id == device_id
|
|||
|
|
).order_by(DeviceHealthScore.timestamp.desc()).limit(1)
|
|||
|
|
)
|
|||
|
|
health = health_result.scalar_one_or_none()
|
|||
|
|
|
|||
|
|
# Generate findings based on device type
|
|||
|
|
if device.device_type == "pv_inverter":
|
|||
|
|
findings, recommendations, energy_loss_kwh, cost_impact_yuan = \
|
|||
|
|
await _diagnose_pv(session, device, anomalies, alarms, health, now)
|
|||
|
|
elif device.device_type == "heat_pump":
|
|||
|
|
findings, recommendations, energy_loss_kwh, cost_impact_yuan = \
|
|||
|
|
await _diagnose_heat_pump(session, device, anomalies, alarms, health, now)
|
|||
|
|
else:
|
|||
|
|
findings, recommendations, energy_loss_kwh, cost_impact_yuan = \
|
|||
|
|
_diagnose_general(device, anomalies, alarms, health)
|
|||
|
|
|
|||
|
|
report = DiagnosticReport(
|
|||
|
|
device_id=device_id,
|
|||
|
|
generated_at=now,
|
|||
|
|
report_type=report_type,
|
|||
|
|
findings=findings,
|
|||
|
|
recommendations=recommendations,
|
|||
|
|
estimated_impact={"energy_loss_kwh": round(energy_loss_kwh, 2), "cost_impact_yuan": round(cost_impact_yuan, 2)},
|
|||
|
|
status="generated",
|
|||
|
|
)
|
|||
|
|
session.add(report)
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _diagnose_pv(session, device, anomalies, alarms, health, now):
|
|||
|
|
"""Diagnostic logic for PV inverters."""
|
|||
|
|
findings = []
|
|||
|
|
recommendations = []
|
|||
|
|
energy_loss = 0.0
|
|||
|
|
cost_impact = 0.0
|
|||
|
|
|
|||
|
|
# Check temperature
|
|||
|
|
cutoff = now - timedelta(hours=6)
|
|||
|
|
temp_result = await session.execute(
|
|||
|
|
select(func.avg(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.device_id == device.id,
|
|||
|
|
EnergyData.data_type == "temperature",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
avg_temp = temp_result.scalar()
|
|||
|
|
|
|||
|
|
power_anomalies = [a for a in anomalies if a.anomaly_type == "power_drop"]
|
|||
|
|
temp_anomalies = [a for a in anomalies if a.anomaly_type == "abnormal_temperature"]
|
|||
|
|
|
|||
|
|
if power_anomalies and avg_temp and float(avg_temp) > 55:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "光伏逆变器功率下降伴随高温",
|
|||
|
|
"severity": "warning",
|
|||
|
|
"detail": f"平均温度 {float(avg_temp):.1f}°C,高于正常范围。功率异常 {len(power_anomalies)} 次",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "清洁光伏面板并检查通风散热",
|
|||
|
|
"priority": "high",
|
|||
|
|
"detail": "高温导致逆变器降额运行,建议清洁面板、检查散热风扇",
|
|||
|
|
})
|
|||
|
|
energy_loss = len(power_anomalies) * 5.0
|
|||
|
|
cost_impact = energy_loss * 0.6
|
|||
|
|
|
|||
|
|
elif power_anomalies:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "光伏功率间歇性下降",
|
|||
|
|
"severity": "info",
|
|||
|
|
"detail": f"近7天检测到 {len(power_anomalies)} 次功率下降",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "检查光伏面板遮挡和接线",
|
|||
|
|
"priority": "medium",
|
|||
|
|
"detail": "排除树木遮挡、面板污染或接线松动",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if alarms:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"近7天触发 {len(alarms)} 条告警",
|
|||
|
|
"severity": "warning" if len(alarms) > 3 else "info",
|
|||
|
|
"detail": "频繁告警可能指示设备劣化",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if health and health.health_score < 70:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"设备健康评分偏低: {health.health_score}",
|
|||
|
|
"severity": "warning",
|
|||
|
|
"detail": f"当前趋势: {health.trend}",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "安排专项巡检",
|
|||
|
|
"priority": "high",
|
|||
|
|
"detail": "建议安排技术人员进行全面检查",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if not findings:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "设备运行状态良好",
|
|||
|
|
"severity": "info",
|
|||
|
|
"detail": "未发现明显异常",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return findings, recommendations, energy_loss, cost_impact
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _diagnose_heat_pump(session, device, anomalies, alarms, health, now):
|
|||
|
|
"""Diagnostic logic for heat pumps."""
|
|||
|
|
findings = []
|
|||
|
|
recommendations = []
|
|||
|
|
energy_loss = 0.0
|
|||
|
|
cost_impact = 0.0
|
|||
|
|
|
|||
|
|
# Check outdoor temperature for context
|
|||
|
|
cutoff = now - timedelta(hours=6)
|
|||
|
|
cop_anomalies = [a for a in anomalies if a.anomaly_type == "efficiency_loss"]
|
|||
|
|
|
|||
|
|
# Check outdoor temp via sensors
|
|||
|
|
outdoor_result = await session.execute(
|
|||
|
|
select(func.avg(EnergyData.value)).where(and_(
|
|||
|
|
EnergyData.data_type == "outdoor_temp",
|
|||
|
|
EnergyData.timestamp >= cutoff,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
outdoor_temp = outdoor_result.scalar()
|
|||
|
|
|
|||
|
|
if cop_anomalies and outdoor_temp and float(outdoor_temp) < -5:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "热泵COP下降,与低温天气相关",
|
|||
|
|
"severity": "info",
|
|||
|
|
"detail": f"室外温度 {float(outdoor_temp):.1f}°C,COP降额属正常现象",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "调整运行策略以适应低温",
|
|||
|
|
"priority": "low",
|
|||
|
|
"detail": "低温环境下COP降低属正常特性,可适当调整运行时段",
|
|||
|
|
})
|
|||
|
|
elif cop_anomalies:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "热泵能效异常下降",
|
|||
|
|
"severity": "warning",
|
|||
|
|
"detail": f"检测到 {len(cop_anomalies)} 次COP异常",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "检查冷媒充注量和换热器",
|
|||
|
|
"priority": "high",
|
|||
|
|
"detail": "COP异常降低可能由冷媒泄漏或换热器结垢导致",
|
|||
|
|
})
|
|||
|
|
energy_loss = len(cop_anomalies) * 8.0
|
|||
|
|
cost_impact = energy_loss * 0.6
|
|||
|
|
|
|||
|
|
comm_anomalies = [a for a in anomalies if a.anomaly_type == "communication_loss"]
|
|||
|
|
if comm_anomalies:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"通讯中断 {len(comm_anomalies)} 次",
|
|||
|
|
"severity": "warning",
|
|||
|
|
"detail": "频繁通讯丢失需检查网络和DTU设备",
|
|||
|
|
})
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "检查通讯网络和DTU",
|
|||
|
|
"priority": "medium",
|
|||
|
|
"detail": "检查DTU设备状态、网线连接和信号强度",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if health and health.health_score < 70:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"设备健康评分偏低: {health.health_score}",
|
|||
|
|
"severity": "warning",
|
|||
|
|
"detail": f"当前趋势: {health.trend}",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if not findings:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "设备运行状态良好",
|
|||
|
|
"severity": "info",
|
|||
|
|
"detail": "未发现明显异常",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return findings, recommendations, energy_loss, cost_impact
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _diagnose_general(device, anomalies, alarms, health):
|
|||
|
|
"""Generic diagnostic for other device types."""
|
|||
|
|
findings = []
|
|||
|
|
recommendations = []
|
|||
|
|
energy_loss = 0.0
|
|||
|
|
cost_impact = 0.0
|
|||
|
|
|
|||
|
|
if anomalies:
|
|||
|
|
by_type = {}
|
|||
|
|
for a in anomalies:
|
|||
|
|
by_type.setdefault(a.anomaly_type, []).append(a)
|
|||
|
|
for atype, items in by_type.items():
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"检测到 {len(items)} 次 {atype} 异常",
|
|||
|
|
"severity": "warning" if len(items) > 2 else "info",
|
|||
|
|
"detail": items[0].description if items else "",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if alarms:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": f"近7天触发 {len(alarms)} 条告警",
|
|||
|
|
"severity": "warning" if len(alarms) > 3 else "info",
|
|||
|
|
"detail": "频繁告警需要关注",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if health and health.health_score < 70:
|
|||
|
|
recommendations.append({
|
|||
|
|
"action": "安排设备巡检",
|
|||
|
|
"priority": "high",
|
|||
|
|
"detail": f"健康评分 {health.health_score},趋势 {health.trend}",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if not findings:
|
|||
|
|
findings.append({
|
|||
|
|
"finding": "设备运行正常",
|
|||
|
|
"severity": "info",
|
|||
|
|
"detail": "未发现异常",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return findings, recommendations, energy_loss, cost_impact
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Predictive Maintenance ──────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def generate_maintenance_predictions(session: AsyncSession) -> list[MaintenancePrediction]:
|
|||
|
|
"""Generate maintenance predictions based on health trends and patterns."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
predictions = []
|
|||
|
|
|
|||
|
|
result = await session.execute(select(Device).where(Device.is_active == True))
|
|||
|
|
devices = result.scalars().all()
|
|||
|
|
|
|||
|
|
for device in devices:
|
|||
|
|
# Get recent health scores
|
|||
|
|
health_result = await session.execute(
|
|||
|
|
select(DeviceHealthScore).where(
|
|||
|
|
DeviceHealthScore.device_id == device.id
|
|||
|
|
).order_by(DeviceHealthScore.timestamp.desc()).limit(10)
|
|||
|
|
)
|
|||
|
|
scores = health_result.scalars().all()
|
|||
|
|
|
|||
|
|
if not scores:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
latest = scores[0]
|
|||
|
|
|
|||
|
|
# Rule: health score < 60 and degrading
|
|||
|
|
if latest.health_score < 60 and latest.trend == "degrading":
|
|||
|
|
days_to_failure = max(3, int((latest.health_score - 20) / 5))
|
|||
|
|
predictions.append(MaintenancePrediction(
|
|||
|
|
device_id=device.id,
|
|||
|
|
predicted_at=now,
|
|||
|
|
component=_get_weak_component(latest.factors),
|
|||
|
|
failure_mode="设备性能持续下降,可能导致故障停机",
|
|||
|
|
probability=round(min(0.9, (100 - latest.health_score) / 100 + 0.2), 2),
|
|||
|
|
predicted_failure_date=now + timedelta(days=days_to_failure),
|
|||
|
|
recommended_action="安排全面检修,重点检查薄弱环节",
|
|||
|
|
urgency="critical" if latest.health_score < 40 else "high",
|
|||
|
|
estimated_downtime_hours=4.0 if device.device_type in ("heat_pump", "pv_inverter") else 2.0,
|
|||
|
|
estimated_repair_cost=_estimate_repair_cost(device.device_type),
|
|||
|
|
status="predicted",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Rule: health between 60-75 and degrading
|
|||
|
|
elif 60 <= latest.health_score < 75 and latest.trend == "degrading":
|
|||
|
|
predictions.append(MaintenancePrediction(
|
|||
|
|
device_id=device.id,
|
|||
|
|
predicted_at=now,
|
|||
|
|
component=_get_weak_component(latest.factors),
|
|||
|
|
failure_mode="性能下降趋势,需预防性维护",
|
|||
|
|
probability=round(min(0.6, (80 - latest.health_score) / 100 + 0.1), 2),
|
|||
|
|
predicted_failure_date=now + timedelta(days=14),
|
|||
|
|
recommended_action="安排预防性巡检和维护",
|
|||
|
|
urgency="medium",
|
|||
|
|
estimated_downtime_hours=2.0,
|
|||
|
|
estimated_repair_cost=_estimate_repair_cost(device.device_type) * 0.5,
|
|||
|
|
status="predicted",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Rule: check alarm frequency
|
|||
|
|
alarm_score = latest.factors.get("alarm_frequency", 100) if latest.factors else 100
|
|||
|
|
if alarm_score < 50:
|
|||
|
|
predictions.append(MaintenancePrediction(
|
|||
|
|
device_id=device.id,
|
|||
|
|
predicted_at=now,
|
|||
|
|
component="告警系统/传感器",
|
|||
|
|
failure_mode="频繁告警可能预示设备故障",
|
|||
|
|
probability=0.4,
|
|||
|
|
predicted_failure_date=now + timedelta(days=7),
|
|||
|
|
recommended_action="排查告警根因,检查传感器和控制系统",
|
|||
|
|
urgency="medium",
|
|||
|
|
estimated_downtime_hours=1.0,
|
|||
|
|
estimated_repair_cost=500.0,
|
|||
|
|
status="predicted",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
for p in predictions:
|
|||
|
|
session.add(p)
|
|||
|
|
return predictions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_weak_component(factors: dict | None) -> str:
|
|||
|
|
"""Identify the weakest factor as the component needing attention."""
|
|||
|
|
if not factors:
|
|||
|
|
return "综合"
|
|||
|
|
component_map = {
|
|||
|
|
"power_stability": "电力输出系统",
|
|||
|
|
"efficiency": "能效/换热系统",
|
|||
|
|
"alarm_frequency": "监控传感器",
|
|||
|
|
"uptime": "通讯/控制系统",
|
|||
|
|
"temperature": "散热/温控系统",
|
|||
|
|
}
|
|||
|
|
weakest = min(factors, key=lambda k: factors.get(k, 100))
|
|||
|
|
return component_map.get(weakest, "综合")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _estimate_repair_cost(device_type: str) -> float:
|
|||
|
|
"""Estimate repair cost by device type."""
|
|||
|
|
costs = {
|
|||
|
|
"pv_inverter": 3000.0,
|
|||
|
|
"heat_pump": 5000.0,
|
|||
|
|
"meter": 800.0,
|
|||
|
|
"sensor": 300.0,
|
|||
|
|
"heat_meter": 1500.0,
|
|||
|
|
}
|
|||
|
|
return costs.get(device_type, 1000.0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Operational Insights ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def generate_insights(session: AsyncSession) -> list[OpsInsight]:
|
|||
|
|
"""Generate operational insights from data analysis."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
insights = []
|
|||
|
|
|
|||
|
|
# Insight 1: Device efficiency comparison
|
|||
|
|
result = await session.execute(
|
|||
|
|
select(
|
|||
|
|
DeviceHealthScore.device_id,
|
|||
|
|
func.avg(DeviceHealthScore.health_score).label("avg_score"),
|
|||
|
|
).where(
|
|||
|
|
DeviceHealthScore.timestamp >= now - timedelta(days=7)
|
|||
|
|
).group_by(DeviceHealthScore.device_id)
|
|||
|
|
)
|
|||
|
|
scores_by_device = result.all()
|
|||
|
|
|
|||
|
|
if scores_by_device:
|
|||
|
|
scores = [float(s.avg_score) for s in scores_by_device]
|
|||
|
|
avg_all = sum(scores) / len(scores) if scores else 0
|
|||
|
|
low_performers = [s for s in scores_by_device if float(s.avg_score) < avg_all - 10]
|
|||
|
|
|
|||
|
|
if low_performers:
|
|||
|
|
device_ids = [s.device_id for s in low_performers]
|
|||
|
|
dev_result = await session.execute(
|
|||
|
|
select(Device.id, Device.name).where(Device.id.in_(device_ids))
|
|||
|
|
)
|
|||
|
|
device_names = {r.id: r.name for r in dev_result.all()}
|
|||
|
|
insights.append(OpsInsight(
|
|||
|
|
insight_type="performance_comparison",
|
|||
|
|
title="部分设备健康评分低于平均水平",
|
|||
|
|
description=f"以下设备健康评分低于园区平均值({avg_all:.0f})超过10分: "
|
|||
|
|
+ ", ".join(device_names.get(d, f"#{d}") for d in device_ids),
|
|||
|
|
data={"avg_score": round(avg_all, 1), "low_performers": [
|
|||
|
|
{"device_id": s.device_id, "score": round(float(s.avg_score), 1),
|
|||
|
|
"name": device_names.get(s.device_id, f"#{s.device_id}")}
|
|||
|
|
for s in low_performers
|
|||
|
|
]},
|
|||
|
|
impact_level="medium" if len(low_performers) <= 2 else "high",
|
|||
|
|
actionable=True,
|
|||
|
|
recommended_action="重点关注低评分设备,安排巡检和维护",
|
|||
|
|
generated_at=now,
|
|||
|
|
valid_until=now + timedelta(days=7),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Insight 2: Anomaly trend
|
|||
|
|
week_ago = now - timedelta(days=7)
|
|||
|
|
two_weeks_ago = now - timedelta(days=14)
|
|||
|
|
|
|||
|
|
this_week = await session.execute(
|
|||
|
|
select(func.count(AnomalyDetection.id)).where(
|
|||
|
|
AnomalyDetection.detected_at >= week_ago
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
last_week = await session.execute(
|
|||
|
|
select(func.count(AnomalyDetection.id)).where(and_(
|
|||
|
|
AnomalyDetection.detected_at >= two_weeks_ago,
|
|||
|
|
AnomalyDetection.detected_at < week_ago,
|
|||
|
|
))
|
|||
|
|
)
|
|||
|
|
this_count = this_week.scalar() or 0
|
|||
|
|
last_count = last_week.scalar() or 0
|
|||
|
|
|
|||
|
|
if this_count > last_count * 1.5 and this_count > 3:
|
|||
|
|
insights.append(OpsInsight(
|
|||
|
|
insight_type="efficiency_trend",
|
|||
|
|
title="异常检测数量环比上升",
|
|||
|
|
description=f"本周检测到 {this_count} 次异常,上周 {last_count} 次,增长 {((this_count/max(1,last_count))-1)*100:.0f}%",
|
|||
|
|
data={"this_week": this_count, "last_week": last_count},
|
|||
|
|
impact_level="high",
|
|||
|
|
actionable=True,
|
|||
|
|
recommended_action="建议全面排查设备状态,加强巡检频次",
|
|||
|
|
generated_at=now,
|
|||
|
|
valid_until=now + timedelta(days=3),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# Insight 3: Energy cost optimization
|
|||
|
|
insights.append(OpsInsight(
|
|||
|
|
insight_type="cost_anomaly",
|
|||
|
|
title="能源使用效率周报",
|
|||
|
|
description="园区整体运行状况评估",
|
|||
|
|
data={
|
|||
|
|
"total_devices": len(scores_by_device) if scores_by_device else 0,
|
|||
|
|
"avg_health": round(avg_all, 1) if scores_by_device else 0,
|
|||
|
|
"anomaly_count": this_count,
|
|||
|
|
},
|
|||
|
|
impact_level="low",
|
|||
|
|
actionable=False,
|
|||
|
|
generated_at=now,
|
|||
|
|
valid_until=now + timedelta(days=7),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
for i in insights:
|
|||
|
|
session.add(i)
|
|||
|
|
return insights
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Dashboard Aggregation ───────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async def get_dashboard_data(session: AsyncSession) -> dict:
|
|||
|
|
"""Get AI Ops dashboard overview data."""
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
|
|||
|
|
# Latest health scores per device
|
|||
|
|
subq = (
|
|||
|
|
select(
|
|||
|
|
DeviceHealthScore.device_id,
|
|||
|
|
func.max(DeviceHealthScore.timestamp).label("max_ts"),
|
|||
|
|
).group_by(DeviceHealthScore.device_id).subquery()
|
|||
|
|
)
|
|||
|
|
health_result = await session.execute(
|
|||
|
|
select(DeviceHealthScore).join(
|
|||
|
|
subq, and_(
|
|||
|
|
DeviceHealthScore.device_id == subq.c.device_id,
|
|||
|
|
DeviceHealthScore.timestamp == subq.c.max_ts,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
health_scores = health_result.scalars().all()
|
|||
|
|
|
|||
|
|
# Get device names
|
|||
|
|
device_ids = [h.device_id for h in health_scores]
|
|||
|
|
if device_ids:
|
|||
|
|
dev_result = await session.execute(
|
|||
|
|
select(Device.id, Device.name, Device.device_type).where(Device.id.in_(device_ids))
|
|||
|
|
)
|
|||
|
|
device_map = {r.id: {"name": r.name, "type": r.device_type} for r in dev_result.all()}
|
|||
|
|
else:
|
|||
|
|
device_map = {}
|
|||
|
|
|
|||
|
|
health_summary = {
|
|||
|
|
"healthy": sum(1 for h in health_scores if h.status == "healthy"),
|
|||
|
|
"warning": sum(1 for h in health_scores if h.status == "warning"),
|
|||
|
|
"critical": sum(1 for h in health_scores if h.status == "critical"),
|
|||
|
|
"avg_score": round(sum(h.health_score for h in health_scores) / max(1, len(health_scores)), 1),
|
|||
|
|
"devices": [{
|
|||
|
|
"device_id": h.device_id,
|
|||
|
|
"device_name": device_map.get(h.device_id, {}).get("name", f"#{h.device_id}"),
|
|||
|
|
"device_type": device_map.get(h.device_id, {}).get("type", "unknown"),
|
|||
|
|
"health_score": h.health_score,
|
|||
|
|
"status": h.status,
|
|||
|
|
"trend": h.trend,
|
|||
|
|
"factors": h.factors,
|
|||
|
|
} for h in health_scores],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Recent anomalies
|
|||
|
|
anomaly_result = await session.execute(
|
|||
|
|
select(AnomalyDetection)
|
|||
|
|
.where(AnomalyDetection.detected_at >= now - timedelta(days=7))
|
|||
|
|
.order_by(AnomalyDetection.detected_at.desc())
|
|||
|
|
.limit(20)
|
|||
|
|
)
|
|||
|
|
recent_anomalies = anomaly_result.scalars().all()
|
|||
|
|
anomaly_stats = {
|
|||
|
|
"total": len(recent_anomalies),
|
|||
|
|
"by_severity": {},
|
|||
|
|
"by_type": {},
|
|||
|
|
}
|
|||
|
|
for a in recent_anomalies:
|
|||
|
|
anomaly_stats["by_severity"][a.severity] = anomaly_stats["by_severity"].get(a.severity, 0) + 1
|
|||
|
|
anomaly_stats["by_type"][a.anomaly_type] = anomaly_stats["by_type"].get(a.anomaly_type, 0) + 1
|
|||
|
|
|
|||
|
|
# Maintenance predictions
|
|||
|
|
pred_result = await session.execute(
|
|||
|
|
select(MaintenancePrediction).where(
|
|||
|
|
MaintenancePrediction.status == "predicted"
|
|||
|
|
).order_by(MaintenancePrediction.urgency.desc()).limit(10)
|
|||
|
|
)
|
|||
|
|
predictions = pred_result.scalars().all()
|
|||
|
|
|
|||
|
|
# Latest insights
|
|||
|
|
insight_result = await session.execute(
|
|||
|
|
select(OpsInsight).where(
|
|||
|
|
OpsInsight.valid_until >= now
|
|||
|
|
).order_by(OpsInsight.generated_at.desc()).limit(5)
|
|||
|
|
)
|
|||
|
|
latest_insights = insight_result.scalars().all()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"health": health_summary,
|
|||
|
|
"anomalies": {
|
|||
|
|
"stats": anomaly_stats,
|
|||
|
|
"recent": [{
|
|||
|
|
"id": a.id,
|
|||
|
|
"device_id": a.device_id,
|
|||
|
|
"device_name": device_map.get(a.device_id, {}).get("name", f"#{a.device_id}"),
|
|||
|
|
"anomaly_type": a.anomaly_type,
|
|||
|
|
"severity": a.severity,
|
|||
|
|
"description": a.description,
|
|||
|
|
"detected_at": str(a.detected_at),
|
|||
|
|
"status": a.status,
|
|||
|
|
} for a in recent_anomalies[:10]],
|
|||
|
|
},
|
|||
|
|
"predictions": [{
|
|||
|
|
"id": p.id,
|
|||
|
|
"device_id": p.device_id,
|
|||
|
|
"device_name": device_map.get(p.device_id, {}).get("name", f"#{p.device_id}"),
|
|||
|
|
"component": p.component,
|
|||
|
|
"failure_mode": p.failure_mode,
|
|||
|
|
"probability": p.probability,
|
|||
|
|
"predicted_failure_date": str(p.predicted_failure_date) if p.predicted_failure_date else None,
|
|||
|
|
"urgency": p.urgency,
|
|||
|
|
"recommended_action": p.recommended_action,
|
|||
|
|
} for p in predictions],
|
|||
|
|
"insights": [{
|
|||
|
|
"id": i.id,
|
|||
|
|
"insight_type": i.insight_type,
|
|||
|
|
"title": i.title,
|
|||
|
|
"description": i.description,
|
|||
|
|
"impact_level": i.impact_level,
|
|||
|
|
"actionable": i.actionable,
|
|||
|
|
"recommended_action": i.recommended_action,
|
|||
|
|
"generated_at": str(i.generated_at),
|
|||
|
|
} for i in latest_insights],
|
|||
|
|
}
|