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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"project": "ems-core",
|
"project": "ems-core",
|
||||||
"project_version": "1.4.0",
|
"project_version": "1.4.1",
|
||||||
"last_updated": "2026-04-06",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
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")
|
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(branding.router)
|
||||||
api_router.include_router(version.router)
|
api_router.include_router(version.router)
|
||||||
api_router.include_router(kpi.router)
|
api_router.include_router(kpi.router)
|
||||||
|
api_router.include_router(meters.router)
|
||||||
|
|||||||
201
backend/app/api/v1/meters.py
Normal file
201
backend/app/api/v1/meters.py
Normal 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]
|
||||||
@@ -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.energy_strategy import TouPricing, TouPricingPeriod, EnergyStrategy, StrategyExecution, MonthlyCostReport
|
||||||
from app.models.weather import WeatherData, WeatherConfig
|
from app.models.weather import WeatherData, WeatherConfig
|
||||||
from app.models.ai_ops import DeviceHealthScore, AnomalyDetection, DiagnosticReport, MaintenancePrediction, OpsInsight
|
from app.models.ai_ops import DeviceHealthScore, AnomalyDetection, DiagnosticReport, MaintenancePrediction, OpsInsight
|
||||||
|
from app.models.meter import MeterReading
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Role", "AuditLog",
|
"User", "Role", "AuditLog",
|
||||||
@@ -40,4 +41,5 @@ __all__ = [
|
|||||||
"TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport",
|
"TouPricing", "TouPricingPeriod", "EnergyStrategy", "StrategyExecution", "MonthlyCostReport",
|
||||||
"WeatherData", "WeatherConfig",
|
"WeatherData", "WeatherConfig",
|
||||||
"DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight",
|
"DeviceHealthScore", "AnomalyDetection", "DiagnosticReport", "MaintenancePrediction", "OpsInsight",
|
||||||
|
"MeterReading",
|
||||||
]
|
]
|
||||||
|
|||||||
24
backend/app/models/meter.py
Normal file
24
backend/app/models/meter.py
Normal 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) # 原始数据包
|
||||||
Reference in New Issue
Block a user