From 72f4269cd48fb051a2066c609b4033f1b41af4b4 Mon Sep 17 00:00:00 2001 From: Du Wenbo Date: Fri, 10 Apr 2026 09:47:34 +0800 Subject: [PATCH] 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) --- VERSION | 2 +- VERSIONS.json | 4 +- .../009_ai_ops_strategy_weather_prediction.py | 287 ++++++++++++++++++ backend/app/api/router.py | 3 +- backend/app/api/v1/meters.py | 201 ++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/meter.py | 24 ++ 7 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/009_ai_ops_strategy_weather_prediction.py create mode 100644 backend/app/api/v1/meters.py create mode 100644 backend/app/models/meter.py diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/VERSIONS.json b/VERSIONS.json index 2568f43..aa816af 100644 --- a/VERSIONS.json +++ b/VERSIONS.json @@ -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" } diff --git a/backend/alembic/versions/009_ai_ops_strategy_weather_prediction.py b/backend/alembic/versions/009_ai_ops_strategy_weather_prediction.py new file mode 100644 index 0000000..42827af --- /dev/null +++ b/backend/alembic/versions/009_ai_ops_strategy_weather_prediction.py @@ -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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5d6ab5f..ef2f340 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/meters.py b/backend/app/api/v1/meters.py new file mode 100644 index 0000000..33f3247 --- /dev/null +++ b/backend/app/api/v1/meters.py @@ -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] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4f96241..b4d629c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/meter.py b/backend/app/models/meter.py new file mode 100644 index 0000000..bcf256e --- /dev/null +++ b/backend/app/models/meter.py @@ -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) # 原始数据包