Shared backend + frontend for multi-customer EMS deployments. - 12 enterprise modules: quota, cost, charging, maintenance, analysis, etc. - 120+ API endpoints, 37 database tables - Customer config mechanism (CUSTOMER env var + YAML config) - Collectors: Modbus TCP, MQTT, HTTP API, Sungrow iSolarCloud - Frontend: React 19 + Ant Design + ECharts + Three.js - Infrastructure: Redis cache, rate limiting, aggregation engine Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据
|
||
|
||
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="℃"),
|
||
]
|