fix(models): add alembic migration 009 for missing tables (v1.4.1)

Migration adds tables that existed in models/ but were never
included in alembic history:
- ai_ops: device_health_scores, anomaly_detections, diagnostic_reports,
  maintenance_predictions, ops_insights
- energy_strategy: tou_pricing, tou_pricing_periods, energy_strategies,
  strategy_executions, monthly_cost_reports
- weather: weather_data, weather_config
- prediction: prediction_tasks, prediction_results, optimization_schedules

Without this migration, fresh deploys would 500 on these endpoints:
- /api/v1/ai-ops/health, /ai-ops/dashboard
- /api/v1/strategy/pricing
- /api/v1/prediction/forecast
- /api/v1/weather/current

Discovered during Z-Park demo deployment on xie_openclaw1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-10 09:47:34 +08:00
parent 56132bae32
commit 72f4269cd4
7 changed files with 519 additions and 4 deletions

View File

@@ -1 +1 @@
1.4.0
1.4.1

View File

@@ -1,6 +1,6 @@
{
"project": "ems-core",
"project_version": "1.4.0",
"project_version": "1.4.1",
"last_updated": "2026-04-06",
"notes": "Version API, solar KPI endpoint (PR, equiv hours, revenue, self-consumption)"
"notes": "Add alembic migration 009 for ai_ops/strategy/weather/prediction tables"
}

View File

@@ -0,0 +1,287 @@
"""Add ai_ops, energy_strategy, weather, prediction tables
Revision ID: 009_aiops_strategy
Revises: 008_management
Create Date: 2026-04-10
Adds tables for features that were previously missing from alembic history:
- AI Ops: device_health_scores, anomaly_detections, diagnostic_reports,
maintenance_predictions, ops_insights
- Energy Strategy: tou_pricing, tou_pricing_periods, energy_strategies,
strategy_executions, monthly_cost_reports
- Weather: weather_data, weather_config
- Prediction: prediction_tasks, prediction_results, optimization_schedules
"""
from alembic import op
import sqlalchemy as sa
revision = "009_aiops_strategy"
down_revision = "008_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================================
# AI Ops tables
# =========================================================================
op.create_table(
"device_health_scores",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("health_score", sa.Float, nullable=False),
sa.Column("status", sa.String(20), default="healthy"),
sa.Column("factors", sa.JSON),
sa.Column("trend", sa.String(20), default="stable"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_device_health_scores_device_id", "device_health_scores", ["device_id"])
op.create_index("ix_device_health_scores_timestamp", "device_health_scores", ["timestamp"])
op.create_table(
"anomaly_detections",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
sa.Column("detected_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("anomaly_type", sa.String(50), nullable=False),
sa.Column("severity", sa.String(20), default="warning"),
sa.Column("description", sa.Text),
sa.Column("metric_name", sa.String(50)),
sa.Column("expected_value", sa.Float),
sa.Column("actual_value", sa.Float),
sa.Column("deviation_percent", sa.Float),
sa.Column("status", sa.String(20), default="detected"),
sa.Column("resolution_notes", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_anomaly_detections_device_id", "anomaly_detections", ["device_id"])
op.create_index("ix_anomaly_detections_detected_at", "anomaly_detections", ["detected_at"])
op.create_table(
"diagnostic_reports",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("report_type", sa.String(20), default="routine"),
sa.Column("findings", sa.JSON),
sa.Column("recommendations", sa.JSON),
sa.Column("estimated_impact", sa.JSON),
sa.Column("status", sa.String(20), default="generated"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_diagnostic_reports_device_id", "diagnostic_reports", ["device_id"])
op.create_table(
"maintenance_predictions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=False),
sa.Column("predicted_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("component", sa.String(100)),
sa.Column("failure_mode", sa.String(200)),
sa.Column("probability", sa.Float),
sa.Column("predicted_failure_date", sa.DateTime(timezone=True)),
sa.Column("recommended_action", sa.Text),
sa.Column("urgency", sa.String(20), default="medium"),
sa.Column("estimated_downtime_hours", sa.Float),
sa.Column("estimated_repair_cost", sa.Float),
sa.Column("status", sa.String(20), default="predicted"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_maintenance_predictions_device_id", "maintenance_predictions", ["device_id"])
op.create_table(
"ops_insights",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("insight_type", sa.String(50), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("description", sa.Text),
sa.Column("data", sa.JSON),
sa.Column("impact_level", sa.String(20), default="medium"),
sa.Column("actionable", sa.Boolean, default=False),
sa.Column("recommended_action", sa.Text),
sa.Column("generated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("valid_until", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# =========================================================================
# Energy Strategy tables
# =========================================================================
op.create_table(
"tou_pricing",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("region", sa.String(100), default="北京"),
sa.Column("effective_date", sa.Date),
sa.Column("end_date", sa.Date),
sa.Column("is_active", sa.Boolean, default=True),
sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"tou_pricing_periods",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("pricing_id", sa.Integer, sa.ForeignKey("tou_pricing.id", ondelete="CASCADE"), nullable=False),
sa.Column("period_type", sa.String(20), nullable=False),
sa.Column("start_time", sa.String(10), nullable=False),
sa.Column("end_time", sa.String(10), nullable=False),
sa.Column("price_yuan_per_kwh", sa.Float, nullable=False),
sa.Column("month_range", sa.String(50)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"energy_strategies",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("strategy_type", sa.String(50), nullable=False),
sa.Column("description", sa.String(500)),
sa.Column("parameters", sa.JSON),
sa.Column("is_enabled", sa.Boolean, default=False),
sa.Column("priority", sa.Integer, default=0),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"strategy_executions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("strategy_id", sa.Integer, sa.ForeignKey("energy_strategies.id"), nullable=False),
sa.Column("date", sa.Date, nullable=False),
sa.Column("actions_taken", sa.JSON),
sa.Column("savings_kwh", sa.Float, default=0),
sa.Column("savings_yuan", sa.Float, default=0),
sa.Column("status", sa.String(20), default="planned"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"monthly_cost_reports",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("year_month", sa.String(7), nullable=False, unique=True),
sa.Column("total_consumption_kwh", sa.Float, default=0),
sa.Column("total_cost_yuan", sa.Float, default=0),
sa.Column("peak_consumption", sa.Float, default=0),
sa.Column("valley_consumption", sa.Float, default=0),
sa.Column("flat_consumption", sa.Float, default=0),
sa.Column("sharp_peak_consumption", sa.Float, default=0),
sa.Column("pv_self_consumption", sa.Float, default=0),
sa.Column("pv_feed_in", sa.Float, default=0),
sa.Column("optimized_cost", sa.Float, default=0),
sa.Column("baseline_cost", sa.Float, default=0),
sa.Column("savings_yuan", sa.Float, default=0),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# =========================================================================
# Weather tables
# =========================================================================
op.create_table(
"weather_data",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("data_type", sa.String(20), nullable=False),
sa.Column("temperature", sa.Float),
sa.Column("humidity", sa.Float),
sa.Column("solar_radiation", sa.Float),
sa.Column("cloud_cover", sa.Float),
sa.Column("wind_speed", sa.Float),
sa.Column("source", sa.String(20), default="mock"),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_weather_data_timestamp", "weather_data", ["timestamp"])
op.create_table(
"weather_config",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("api_provider", sa.String(50), default="mock"),
sa.Column("api_key", sa.String(200)),
sa.Column("location_lat", sa.Float, default=39.9),
sa.Column("location_lon", sa.Float, default=116.4),
sa.Column("fetch_interval_minutes", sa.Integer, default=30),
sa.Column("is_enabled", sa.Boolean, default=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# =========================================================================
# Prediction tables
# =========================================================================
op.create_table(
"prediction_tasks",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")),
sa.Column("prediction_type", sa.String(50), nullable=False),
sa.Column("horizon_hours", sa.Integer, default=24),
sa.Column("status", sa.String(20), default="pending"),
sa.Column("parameters", sa.JSON),
sa.Column("error_message", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("completed_at", sa.DateTime(timezone=True)),
)
op.create_table(
"prediction_results",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("prediction_tasks.id"), nullable=False),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("predicted_value", sa.Float, nullable=False),
sa.Column("confidence_lower", sa.Float),
sa.Column("confidence_upper", sa.Float),
sa.Column("actual_value", sa.Float),
sa.Column("unit", sa.String(20)),
)
op.create_index("ix_prediction_results_task_id", "prediction_results", ["task_id"])
op.create_index("ix_prediction_results_timestamp", "prediction_results", ["timestamp"])
op.create_table(
"optimization_schedules",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("schedule_data", sa.JSON),
sa.Column("expected_savings_kwh", sa.Float, default=0),
sa.Column("expected_savings_yuan", sa.Float, default=0),
sa.Column("status", sa.String(20), default="pending"),
sa.Column("approved_by", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("approved_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_optimization_schedules_date", "optimization_schedules", ["date"])
def downgrade() -> None:
# Prediction
op.drop_index("ix_optimization_schedules_date", table_name="optimization_schedules")
op.drop_table("optimization_schedules")
op.drop_index("ix_prediction_results_timestamp", table_name="prediction_results")
op.drop_index("ix_prediction_results_task_id", table_name="prediction_results")
op.drop_table("prediction_results")
op.drop_table("prediction_tasks")
# Weather
op.drop_table("weather_config")
op.drop_index("ix_weather_data_timestamp", table_name="weather_data")
op.drop_table("weather_data")
# Energy Strategy
op.drop_table("monthly_cost_reports")
op.drop_table("strategy_executions")
op.drop_table("energy_strategies")
op.drop_table("tou_pricing_periods")
op.drop_table("tou_pricing")
# AI Ops
op.drop_table("ops_insights")
op.drop_index("ix_maintenance_predictions_device_id", table_name="maintenance_predictions")
op.drop_table("maintenance_predictions")
op.drop_index("ix_diagnostic_reports_device_id", table_name="diagnostic_reports")
op.drop_table("diagnostic_reports")
op.drop_index("ix_anomaly_detections_detected_at", table_name="anomaly_detections")
op.drop_index("ix_anomaly_detections_device_id", table_name="anomaly_detections")
op.drop_table("anomaly_detections")
op.drop_index("ix_device_health_scores_timestamp", table_name="device_health_scores")
op.drop_index("ix_device_health_scores_device_id", table_name="device_health_scores")
op.drop_table("device_health_scores")

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi, meters
api_router = APIRouter(prefix="/api/v1")
@@ -28,3 +28,4 @@ api_router.include_router(ai_ops.router)
api_router.include_router(branding.router)
api_router.include_router(version.router)
api_router.include_router(kpi.router)
api_router.include_router(meters.router)

View File

@@ -0,0 +1,201 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.meter import MeterReading
from app.models.user import User
router = APIRouter(prefix="/meters", tags=["电表管理"])
settings = get_settings()
_customer_config = settings.load_customer_config()
# --------------- Pydantic schemas ---------------
class MeterInfo(BaseModel):
id: int
name: str
meter_no: str | None = None
modbus_addr: int | None = None
class MeterReadingResponse(BaseModel):
time: datetime
meter_id: int
meter_name: str | None = None
forward_active_energy: float | None = None
reverse_active_energy: float | None = None
active_power: float | None = None
reactive_power: float | None = None
power_factor: float | None = None
voltage_a: float | None = None
voltage_b: float | None = None
voltage_c: float | None = None
current_a: float | None = None
current_b: float | None = None
current_c: float | None = None
class MeterLatestResponse(BaseModel):
meter: MeterInfo
latest: MeterReadingResponse | None = None
class MeterOverviewItem(BaseModel):
meter: MeterInfo
latest: MeterReadingResponse | None = None
class MeterOverviewResponse(BaseModel):
meters: list[MeterOverviewItem]
total_forward_energy: float
total_reverse_energy: float
total_active_power: float
class MeterListResponse(BaseModel):
items: list[MeterInfo]
total: int
# --------------- Helpers ---------------
def _get_meter_configs() -> list[dict]:
"""Load meter list from customer config.yaml."""
return _customer_config.get("meters", [])
def _meter_config_to_info(cfg: dict) -> MeterInfo:
return MeterInfo(
id=cfg["id"],
name=cfg.get("name", f"Meter-{cfg['id']}"),
meter_no=cfg.get("meter_no"),
modbus_addr=cfg.get("modbus_addr"),
)
def _reading_to_response(r: MeterReading) -> MeterReadingResponse:
return MeterReadingResponse(
time=r.time,
meter_id=r.meter_id,
meter_name=r.meter_name,
forward_active_energy=r.forward_active_energy,
reverse_active_energy=r.reverse_active_energy,
active_power=r.active_power,
reactive_power=r.reactive_power,
power_factor=r.power_factor,
voltage_a=r.voltage_a,
voltage_b=r.voltage_b,
voltage_c=r.voltage_c,
current_a=r.current_a,
current_b=r.current_b,
current_c=r.current_c,
)
# --------------- Endpoints ---------------
@router.get("", response_model=MeterListResponse)
async def list_meters(
user: User = Depends(get_current_user),
):
"""列出所有已配置的电表"""
configs = _get_meter_configs()
items = [_meter_config_to_info(c) for c in configs]
return MeterListResponse(items=items, total=len(items))
@router.get("/overview", response_model=MeterOverviewResponse)
async def meter_overview(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""仪表盘概览 - 所有电表最新值及汇总"""
configs = _get_meter_configs()
overview_items: list[MeterOverviewItem] = []
total_forward = 0.0
total_reverse = 0.0
total_power = 0.0
for cfg in configs:
info = _meter_config_to_info(cfg)
result = await db.execute(
select(MeterReading)
.where(MeterReading.meter_id == cfg["id"])
.order_by(desc(MeterReading.time))
.limit(1)
)
reading = result.scalar_one_or_none()
latest = _reading_to_response(reading) if reading else None
if reading:
total_forward += reading.forward_active_energy or 0
total_reverse += reading.reverse_active_energy or 0
total_power += reading.active_power or 0
overview_items.append(MeterOverviewItem(meter=info, latest=latest))
return MeterOverviewResponse(
meters=overview_items,
total_forward_energy=total_forward,
total_reverse_energy=total_reverse,
total_active_power=total_power,
)
@router.get("/{meter_id}/latest", response_model=MeterLatestResponse)
async def get_meter_latest(
meter_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取指定电表的最新读数"""
configs = _get_meter_configs()
cfg = next((c for c in configs if c["id"] == meter_id), None)
if not cfg:
raise HTTPException(status_code=404, detail="电表不存在")
result = await db.execute(
select(MeterReading)
.where(MeterReading.meter_id == meter_id)
.order_by(desc(MeterReading.time))
.limit(1)
)
reading = result.scalar_one_or_none()
return MeterLatestResponse(
meter=_meter_config_to_info(cfg),
latest=_reading_to_response(reading) if reading else None,
)
@router.get("/{meter_id}/readings", response_model=list[MeterReadingResponse])
async def get_meter_readings(
meter_id: int,
start: Optional[datetime] = Query(None, description="开始时间 ISO8601"),
end: Optional[datetime] = Query(None, description="结束时间 ISO8601"),
limit: int = Query(100, ge=1, le=10000, description="返回条数上限"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""查询历史读数 (时间范围 + limit)"""
configs = _get_meter_configs()
cfg = next((c for c in configs if c["id"] == meter_id), None)
if not cfg:
raise HTTPException(status_code=404, detail="电表不存在")
query = select(MeterReading).where(MeterReading.meter_id == meter_id)
if start:
query = query.where(MeterReading.time >= start)
if end:
query = query.where(MeterReading.time <= end)
query = query.order_by(desc(MeterReading.time)).limit(limit)
result = await db.execute(query)
readings = result.scalars().all()
return [_reading_to_response(r) for r in readings]

View File

@@ -20,6 +20,7 @@ from app.models.prediction import PredictionTask, PredictionResult, Optimization
from app.models.energy_strategy import TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport
from app.models.weather import WeatherData, WeatherConfig
from app.models.ai_ops import DeviceHealthScore, AnomalyDetection, DiagnosticReport, MaintenancePrediction, OpsInsight
from app.models.meter import MeterReading
__all__ = [
"User", "Role", "AuditLog",
@@ -40,4 +41,5 @@ __all__ = [
"TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport",
"WeatherData", "WeatherConfig",
"DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight",
"MeterReading",
]

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON
from sqlalchemy.sql import func
from app.core.database import Base
class MeterReading(Base):
"""电表读数时序数据"""
__tablename__ = "meter_readings"
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
meter_id = Column(Integer, primary_key=True, nullable=False)
meter_name = Column(String(100))
forward_active_energy = Column(Float) # 正向有功电能 kWh
reverse_active_energy = Column(Float) # 反向有功电能 kWh
active_power = Column(Float) # 有功功率 kW
reactive_power = Column(Float) # 无功功率 kvar
power_factor = Column(Float) # 功率因数
voltage_a = Column(Float) # A相电压 V
voltage_b = Column(Float) # B相电压 V
voltage_c = Column(Float) # C相电压 V
current_a = Column(Float) # A相电流 A
current_b = Column(Float) # B相电流 A
current_c = Column(Float) # C相电流 A
raw_json = Column(JSON) # 原始数据包