Files
tp-ems/backend/app/services/simulator.py
Du Wenbo d8e4449f10 Squashed 'core/' content from commit 92ec910
git-subtree-dir: core
git-subtree-split: 92ec910a132e379a3a6e442a75bcb07cac0f0010
2026-04-04 18:16:49 +08:00

296 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据
Uses physics-based solar position, Beijing weather models, cloud transients,
temperature derating, and realistic building load patterns to produce data
that is convincing to industrial park asset owners.
"""
import asyncio
import random
import math
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from app.core.database import async_session
from app.models.device import Device
from app.models.energy import EnergyData
from app.models.alarm import AlarmEvent
from app.services.alarm_checker import check_alarms
from app.services.weather_model import (
pv_power, pv_electrical, get_pv_orientation,
heat_pump_data, building_load, indoor_sensor,
heat_meter_data, get_hvac_mode, outdoor_temperature,
should_skip_reading, should_go_offline,
)
logger = logging.getLogger("simulator")
class DataSimulator:
def __init__(self):
self._task = None
self._running = False
self._cycle_count = 0
# Track daily energy accumulators per device
self._daily_energy: dict[int, float] = {}
self._total_energy: dict[int, float] = {}
self._last_day: int = -1
# Track offline status per device
self._offline_until: dict[int, datetime] = {}
# Cache heat pump totals for heat meter correlation
self._last_hp_power: float = 0.0
self._last_hp_cop: float = 3.0
async def start(self):
self._running = True
self._task = asyncio.create_task(self._run_loop())
async def stop(self):
self._running = False
if self._task:
self._task.cancel()
async def _run_loop(self):
while self._running:
try:
await self._generate_data()
except Exception as e:
logger.error(f"Simulator error: {e}", exc_info=True)
await asyncio.sleep(15) # 每15秒生成一次
async def _generate_data(self):
now = datetime.now(timezone.utc)
beijing_dt = now + timedelta(hours=8)
self._cycle_count += 1
# Reset daily energy accumulators at midnight Beijing time
current_day = beijing_dt.timetuple().tm_yday
if current_day != self._last_day:
self._daily_energy.clear()
self._last_day = current_day
async with async_session() as session:
result = await session.execute(select(Device).where(Device.is_active == True))
devices = result.scalars().all()
data_points = []
hp_total_power = 0.0
hp_cop_sum = 0.0
hp_count = 0
# First pass: generate heat pump data (needed for heat meter correlation)
hp_results: dict[int, dict] = {}
for device in devices:
if device.device_type == "heat_pump":
hp_data = self._gen_heatpump_data(device, now)
hp_results[device.id] = hp_data
if hp_data:
hp_total_power += hp_data.get("_power", 0)
cop = hp_data.get("_cop", 0)
if cop > 0:
hp_cop_sum += cop
hp_count += 1
self._last_hp_power = hp_total_power
self._last_hp_cop = hp_cop_sum / hp_count if hp_count > 0 else 3.0
for device in devices:
# Simulate communication glitch: skip a reading ~1% of cycles
if should_skip_reading(self._cycle_count):
continue
# Simulate brief device offline events
if device.id in self._offline_until:
if now < self._offline_until[device.id]:
device.status = "offline"
continue
else:
del self._offline_until[device.id]
if should_go_offline():
self._offline_until[device.id] = now + timedelta(seconds=random.randint(15, 30))
device.status = "offline"
continue
points = self._generate_device_data(device, now, hp_results)
data_points.extend(points)
device.status = "online"
device.last_data_time = now
if data_points:
session.add_all(data_points)
await session.flush()
# Run alarm checker after data generation
try:
await check_alarms(session)
except Exception as e:
logger.error(f"Alarm checker error: {e}", exc_info=True)
await session.commit()
def _should_trigger_anomaly(self, anomaly_type: str) -> bool:
"""Determine if we should inject an anomalous value for demo purposes.
Preserves the existing alarm demo trigger pattern:
- PV low power: every ~10 min (40 cycles), lasts ~2 min (8 cycles)
- Heat pump low COP: every ~20 min (80 cycles), lasts ~2 min
- Sensor out of range: every ~30 min (120 cycles), lasts ~2 min
"""
c = self._cycle_count
if anomaly_type == "pv_low_power":
return (c % 40) < 8
elif anomaly_type == "hp_low_cop":
return (c % 80) < 8
elif anomaly_type == "sensor_out_of_range":
return (c % 120) < 8
return False
def _generate_device_data(self, device: Device, now: datetime,
hp_results: dict) -> list[EnergyData]:
points = []
if device.device_type == "pv_inverter":
points = self._gen_pv_data(device, now)
elif device.device_type == "heat_pump":
hp_data = hp_results.get(device.id)
if hp_data:
points = hp_data.get("_points", [])
elif device.device_type == "meter":
points = self._gen_meter_data(device, now)
elif device.device_type == "sensor":
points = self._gen_sensor_data(device, now)
elif device.device_type == "heat_meter":
points = self._gen_heat_meter_data(device, now)
return points
def _gen_pv_data(self, device: Device, now: datetime) -> list[EnergyData]:
"""光伏逆变器数据 - 基于太阳位置、云层、温度降额模型"""
rated = device.rated_power or 110.0
orientation = get_pv_orientation(device.code)
power = pv_power(now, rated_power=rated, orientation=orientation,
device_code=device.code)
# Demo anomaly: cloud cover drops INV-01 power very low for alarm testing
if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01":
power = random.uniform(1.0, 3.0)
elec = pv_electrical(power, rated_power=rated)
# Demo anomaly: over-temperature for alarm testing
if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01":
elec["temperature"] = round(random.uniform(67, 72), 1)
# Accumulate daily energy (power * 15s interval)
interval_hours = 15.0 / 3600.0
energy_increment = power * interval_hours
self._daily_energy[device.id] = self._daily_energy.get(device.id, 0) + energy_increment
# Total energy: start from a reasonable base
if device.id not in self._total_energy:
self._total_energy[device.id] = 170000 + random.uniform(0, 5000)
self._total_energy[device.id] += energy_increment
return [
EnergyData(device_id=device.id, timestamp=now, data_type="power",
value=round(power, 2), unit="kW"),
EnergyData(device_id=device.id, timestamp=now, data_type="daily_energy",
value=round(self._daily_energy[device.id], 2), unit="kWh"),
EnergyData(device_id=device.id, timestamp=now, data_type="total_energy",
value=round(self._total_energy[device.id], 1), unit="kWh"),
EnergyData(device_id=device.id, timestamp=now, data_type="dc_voltage",
value=elec["dc_voltage"], unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="ac_voltage",
value=elec["ac_voltage"], unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="temperature",
value=elec["temperature"], unit=""),
]
def _gen_heatpump_data(self, device: Device, now: datetime) -> dict:
"""热泵机组数据 - 基于室外温度和COP模型"""
rated = device.rated_power or 35.0
data = heat_pump_data(now, rated_power=rated, device_code=device.code)
cop = data["cop"]
power = data["power"]
# Demo anomaly: low COP for HP-01
if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-01":
cop = random.uniform(1.2, 1.8)
# Demo anomaly: overload for HP-02
if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-02":
power = random.uniform(39, 42)
points = [
EnergyData(device_id=device.id, timestamp=now, data_type="power",
value=round(power, 2), unit="kW"),
EnergyData(device_id=device.id, timestamp=now, data_type="cop",
value=round(cop, 2), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="inlet_temp",
value=data["inlet_temp"], unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="outlet_temp",
value=data["outlet_temp"], unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate",
value=data["flow_rate"], unit="m³/h"),
EnergyData(device_id=device.id, timestamp=now, data_type="outdoor_temp",
value=data["outdoor_temp"], unit=""),
]
return {
"_points": points,
"_power": power,
"_cop": cop,
}
def _gen_meter_data(self, device: Device, now: datetime) -> list[EnergyData]:
"""电表数据 - 基于建筑负荷模型(工作日/周末、午餐低谷、HVAC季节贡献"""
data = building_load(now, base_power=50.0, meter_code=device.code)
return [
EnergyData(device_id=device.id, timestamp=now, data_type="power",
value=data["power"], unit="kW"),
EnergyData(device_id=device.id, timestamp=now, data_type="voltage",
value=data["voltage"], unit="V"),
EnergyData(device_id=device.id, timestamp=now, data_type="current",
value=data["current"], unit="A"),
EnergyData(device_id=device.id, timestamp=now, data_type="power_factor",
value=data["power_factor"], unit=""),
]
def _gen_sensor_data(self, device: Device, now: datetime) -> list[EnergyData]:
"""温湿度传感器数据 - 室内HVAC控制 / 室外天气模型"""
is_outdoor = False
if device.metadata_:
is_outdoor = device.metadata_.get("type") == "outdoor"
data = indoor_sensor(now, is_outdoor=is_outdoor, device_code=device.code)
temp = data["temperature"]
# Demo anomaly: sensor out of range for alarm testing
if self._should_trigger_anomaly("sensor_out_of_range") and device.code == "TH-01":
temp = random.uniform(31, 34)
return [
EnergyData(device_id=device.id, timestamp=now, data_type="temperature",
value=round(temp, 1), unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="humidity",
value=data["humidity"], unit="%"),
]
def _gen_heat_meter_data(self, device: Device, now: datetime) -> list[EnergyData]:
"""热量表数据 - 与热泵运行功率和COP相关联"""
data = heat_meter_data(now, hp_power=self._last_hp_power,
hp_cop=self._last_hp_cop)
return [
EnergyData(device_id=device.id, timestamp=now, data_type="heat_power",
value=data["heat_power"], unit="kW"),
EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate",
value=data["flow_rate"], unit="m³/h"),
EnergyData(device_id=device.id, timestamp=now, data_type="supply_temp",
value=data["supply_temp"], unit=""),
EnergyData(device_id=device.id, timestamp=now, data_type="return_temp",
value=data["return_temp"], unit=""),
]