Merge commit 'd8e4449f1009bc03b167c0e5667413585b2b3e53' as 'core'
This commit is contained in:
295
core/backend/app/services/simulator.py
Normal file
295
core/backend/app/services/simulator.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据
|
||||
|
||||
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="℃"),
|
||||
]
|
||||
Reference in New Issue
Block a user