Initial commit: Tianpu Zero-Carbon EMS Platform
Full-stack energy management system for Tianpu Daxing campus. - Frontend: React 19 + TypeScript + Ant Design + ECharts - Backend: FastAPI + SQLAlchemy + PostgreSQL/TimescaleDB - Features: PV monitoring, heat pump management, carbon tracking, alarms, reports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
backend/.env
Normal file
9
backend/.env
Normal file
@@ -0,0 +1,9 @@
|
||||
# Local development with SQLite
|
||||
DATABASE_URL=sqlite+aiosqlite:///./tianpu_ems.db
|
||||
DATABASE_URL_SYNC=sqlite:///./tianpu_ems.db
|
||||
SECRET_KEY=tianpu-ems-secret-key-change-in-production-2026
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=480
|
||||
APP_NAME=TianpuEMS
|
||||
DEBUG=true
|
||||
API_V1_PREFIX=/api/v1
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://tianpu:tianpu2026@localhost:5432/tianpu_ems
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
40
backend/alembic/env.py
Normal file
40
backend/alembic/env.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from app.core.database import Base
|
||||
from app.models import * # noqa
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
14
backend/app/api/router.py
Normal file
14
backend/app/api/router.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(devices.router)
|
||||
api_router.include_router(energy.router)
|
||||
api_router.include_router(monitoring.router)
|
||||
api_router.include_router(alarms.router)
|
||||
api_router.include_router(reports.router)
|
||||
api_router.include_router(carbon.router)
|
||||
api_router.include_router(dashboard.router)
|
||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
146
backend/app/api/v1/alarms.py
Normal file
146
backend/app/api/v1/alarms.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/alarms", tags=["告警管理"])
|
||||
|
||||
|
||||
class AlarmRuleCreate(BaseModel):
|
||||
name: str
|
||||
device_id: int | None = None
|
||||
device_type: str | None = None
|
||||
data_type: str
|
||||
condition: str
|
||||
threshold: float | None = None
|
||||
threshold_high: float | None = None
|
||||
threshold_low: float | None = None
|
||||
duration: int = 0
|
||||
severity: str = "warning"
|
||||
notify_channels: list[str] | None = None
|
||||
notify_targets: list[str] | None = None
|
||||
silence_start: str | None = None
|
||||
silence_end: str | None = None
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_rules(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmRule).order_by(AlarmRule.id.desc()))
|
||||
return [_rule_to_dict(r) for r in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
async def create_rule(data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
rule = AlarmRule(**data.model_dump(), created_by=user.id)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
return _rule_to_dict(rule)
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
async def update_rule(rule_id: int, data: AlarmRuleCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(rule, k, v)
|
||||
return _rule_to_dict(rule)
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
rule.is_active = False
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
device_id: int | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(AlarmEvent)
|
||||
if status:
|
||||
query = query.where(AlarmEvent.status == status)
|
||||
if severity:
|
||||
query = query.where(AlarmEvent.severity == severity)
|
||||
if device_id:
|
||||
query = query.where(AlarmEvent.device_id == device_id)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": e.id, "rule_id": e.rule_id, "device_id": e.device_id, "severity": e.severity,
|
||||
"title": e.title, "description": e.description, "value": e.value, "threshold": e.threshold,
|
||||
"status": e.status, "triggered_at": str(e.triggered_at),
|
||||
"acknowledged_at": str(e.acknowledged_at) if e.acknowledged_at else None,
|
||||
"resolved_at": str(e.resolved_at) if e.resolved_at else None,
|
||||
} for e in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/acknowledge")
|
||||
async def acknowledge_event(event_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
event.status = "acknowledged"
|
||||
event.acknowledged_by = user.id
|
||||
event.acknowledged_at = datetime.now(timezone.utc)
|
||||
return {"message": "已确认"}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/resolve")
|
||||
async def resolve_event(event_id: int, note: str = "", db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(AlarmEvent).where(AlarmEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
event.status = "resolved"
|
||||
event.resolved_at = datetime.now(timezone.utc)
|
||||
event.resolve_note = note
|
||||
return {"message": "已解决"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def alarm_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(AlarmEvent.severity, AlarmEvent.status, func.count(AlarmEvent.id))
|
||||
.group_by(AlarmEvent.severity, AlarmEvent.status)
|
||||
)
|
||||
stats = {}
|
||||
for severity, status, count in result.all():
|
||||
if severity not in stats:
|
||||
stats[severity] = {}
|
||||
stats[severity][status] = count
|
||||
return stats
|
||||
|
||||
|
||||
def _rule_to_dict(r: AlarmRule) -> dict:
|
||||
return {
|
||||
"id": r.id, "name": r.name, "device_id": r.device_id, "device_type": r.device_type,
|
||||
"data_type": r.data_type, "condition": r.condition, "threshold": r.threshold,
|
||||
"threshold_high": r.threshold_high, "threshold_low": r.threshold_low,
|
||||
"duration": r.duration, "severity": r.severity, "is_active": r.is_active,
|
||||
"notify_channels": r.notify_channels, "silence_start": r.silence_start, "silence_end": r.silence_end,
|
||||
}
|
||||
50
backend/app/api/v1/auth.py
Normal file
50
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: dict
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
return Token(
|
||||
access_token=token,
|
||||
user={"id": user.id, "username": user.username, "full_name": user.full_name, "role": user.role}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": user.id, "username": user.username, "full_name": user.full_name,
|
||||
"email": user.email, "phone": user.phone, "role": user.role, "is_active": user.is_active,
|
||||
}
|
||||
74
backend/app/api/v1/carbon.py
Normal file
74
backend/app/api/v1/carbon.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.carbon import CarbonEmission, EmissionFactor
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/carbon", tags=["碳排放管理"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""碳排放总览"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
async def sum_carbon(start, end):
|
||||
r = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||||
.where(and_(CarbonEmission.date >= start, CarbonEmission.date < end))
|
||||
)
|
||||
row = r.first()
|
||||
return {"emission": row[0] or 0, "reduction": row[1] or 0}
|
||||
|
||||
today = await sum_carbon(today_start, now)
|
||||
month = await sum_carbon(month_start, now)
|
||||
year = await sum_carbon(year_start, now)
|
||||
|
||||
# 各scope分布
|
||||
scope_q = await db.execute(
|
||||
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
||||
.where(CarbonEmission.date >= year_start)
|
||||
.group_by(CarbonEmission.scope)
|
||||
)
|
||||
by_scope = {row[0]: round(row[1], 2) for row in scope_q.all()}
|
||||
|
||||
return {
|
||||
"today": {"emission": round(today["emission"], 2), "reduction": round(today["reduction"], 2)},
|
||||
"month": {"emission": round(month["emission"], 2), "reduction": round(month["reduction"], 2)},
|
||||
"year": {"emission": round(year["emission"], 2), "reduction": round(year["reduction"], 2)},
|
||||
"by_scope": by_scope,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trend")
|
||||
async def carbon_trend(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""碳排放趋势"""
|
||||
start = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date_trunc('day', CarbonEmission.date).label('day'),
|
||||
func.sum(CarbonEmission.emission),
|
||||
func.sum(CarbonEmission.reduction),
|
||||
).where(CarbonEmission.date >= start)
|
||||
.group_by(text('day')).order_by(text('day'))
|
||||
)
|
||||
return [{"date": str(r[0]), "emission": round(r[1], 2), "reduction": round(r[2], 2)} for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/factors")
|
||||
async def list_factors(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(EmissionFactor).order_by(EmissionFactor.id))
|
||||
return [{
|
||||
"id": f.id, "name": f.name, "energy_type": f.energy_type, "factor": f.factor,
|
||||
"unit": f.unit, "scope": f.scope, "region": f.region, "source": f.source,
|
||||
} for f in result.scalars().all()]
|
||||
139
backend/app/api/v1/dashboard.py
Normal file
139
backend/app/api/v1/dashboard.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData, EnergyDailySummary
|
||||
from app.models.alarm import AlarmEvent
|
||||
from app.models.carbon import CarbonEmission
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["大屏数据"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""能源总览大屏核心数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# 设备状态统计
|
||||
device_stats_q = await db.execute(
|
||||
select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status)
|
||||
)
|
||||
device_stats = {row[0]: row[1] for row in device_stats_q.all()}
|
||||
|
||||
# 今日能耗汇总
|
||||
daily_q = await db.execute(
|
||||
select(
|
||||
EnergyDailySummary.energy_type,
|
||||
func.sum(EnergyDailySummary.total_consumption),
|
||||
func.sum(EnergyDailySummary.total_generation),
|
||||
).where(EnergyDailySummary.date >= today_start).group_by(EnergyDailySummary.energy_type)
|
||||
)
|
||||
energy_summary = {}
|
||||
for row in daily_q.all():
|
||||
energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0}
|
||||
|
||||
# 今日碳排放
|
||||
carbon_q = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||||
.where(CarbonEmission.date >= today_start)
|
||||
)
|
||||
carbon_row = carbon_q.first()
|
||||
|
||||
# 活跃告警数
|
||||
alarm_count_q = await db.execute(
|
||||
select(func.count(AlarmEvent.id)).where(AlarmEvent.status == "active")
|
||||
)
|
||||
active_alarms = alarm_count_q.scalar() or 0
|
||||
|
||||
# 最近告警
|
||||
recent_alarms_q = await db.execute(
|
||||
select(AlarmEvent).where(AlarmEvent.status == "active").order_by(AlarmEvent.triggered_at.desc()).limit(10)
|
||||
)
|
||||
recent_alarms = [
|
||||
{"id": a.id, "title": a.title, "severity": a.severity, "device_id": a.device_id,
|
||||
"triggered_at": str(a.triggered_at)}
|
||||
for a in recent_alarms_q.scalars().all()
|
||||
]
|
||||
|
||||
return {
|
||||
"device_stats": {
|
||||
"online": device_stats.get("online", 0),
|
||||
"offline": device_stats.get("offline", 0),
|
||||
"alarm": device_stats.get("alarm", 0),
|
||||
"total": sum(device_stats.values()),
|
||||
},
|
||||
"energy_today": energy_summary,
|
||||
"carbon": {
|
||||
"emission": carbon_row[0] or 0 if carbon_row else 0,
|
||||
"reduction": carbon_row[1] or 0 if carbon_row else 0,
|
||||
},
|
||||
"active_alarms": active_alarms,
|
||||
"recent_alarms": recent_alarms,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/realtime")
|
||||
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""实时功率数据 - 获取最近的采集数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
latest_q = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power")
|
||||
).order_by(EnergyData.timestamp.desc()).limit(50)
|
||||
)
|
||||
data_points = latest_q.scalars().all()
|
||||
|
||||
pv_ids = await _get_pv_device_ids(db)
|
||||
hp_ids = await _get_hp_device_ids(db)
|
||||
pv_power = sum(d.value for d in data_points if d.device_id in pv_ids)
|
||||
heatpump_power = sum(d.value for d in data_points if d.device_id in hp_ids)
|
||||
|
||||
return {
|
||||
"timestamp": str(now),
|
||||
"pv_power": round(pv_power, 2),
|
||||
"heatpump_power": round(heatpump_power, 2),
|
||||
"total_load": round(pv_power + heatpump_power, 2),
|
||||
"grid_power": round(max(0, heatpump_power - pv_power), 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/load-curve")
|
||||
async def get_load_curve(
|
||||
hours: int = 24,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""负荷曲线数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
start = now - timedelta(hours=hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date_trunc('hour', EnergyData.timestamp).label('hour'),
|
||||
func.avg(EnergyData.value).label('avg_power'),
|
||||
).where(
|
||||
and_(EnergyData.timestamp >= start, EnergyData.data_type == "power")
|
||||
).group_by(text('hour')).order_by(text('hour'))
|
||||
)
|
||||
return [{"time": str(row[0]), "power": round(row[1], 2)} for row in result.all()]
|
||||
|
||||
|
||||
async def _get_pv_device_ids(db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.device_type == "pv_inverter", Device.is_active == True)
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
|
||||
async def _get_hp_device_ids(db: AsyncSession):
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.device_type == "heat_pump", Device.is_active == True)
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
122
backend/app/api/v1/devices.py
Normal file
122
backend/app/api/v1/devices.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.models.device import Device, DeviceType, DeviceGroup
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/devices", tags=["设备管理"])
|
||||
|
||||
|
||||
class DeviceCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
device_type: str
|
||||
group_id: int | None = None
|
||||
model: str | None = None
|
||||
manufacturer: str | None = None
|
||||
serial_number: str | None = None
|
||||
rated_power: float | None = None
|
||||
location: str | None = None
|
||||
protocol: str | None = None
|
||||
connection_params: dict | None = None
|
||||
collect_interval: int = 15
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
group_id: int | None = None
|
||||
location: str | None = None
|
||||
protocol: str | None = None
|
||||
connection_params: dict | None = None
|
||||
collect_interval: int | None = None
|
||||
status: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_devices(
|
||||
device_type: str | None = None,
|
||||
group_id: int | None = None,
|
||||
status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
query = select(Device).where(Device.is_active == True)
|
||||
if device_type:
|
||||
query = query.where(Device.device_type == device_type)
|
||||
if group_id:
|
||||
query = query.where(Device.group_id == group_id)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar()
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size).order_by(Device.id)
|
||||
result = await db.execute(query)
|
||||
devices = result.scalars().all()
|
||||
return {"total": total, "items": [_device_to_dict(d) for d in devices]}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
async def list_device_types(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(DeviceType).order_by(DeviceType.id))
|
||||
return [{"id": t.id, "code": t.code, "name": t.name, "icon": t.icon} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_device_groups(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id))
|
||||
return [{"id": g.id, "name": g.name, "parent_id": g.parent_id, "location": g.location} for g in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def device_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(Device.status, func.count(Device.id)).where(Device.is_active == True).group_by(Device.status)
|
||||
)
|
||||
stats = {row[0]: row[1] for row in result.all()}
|
||||
return {"online": stats.get("online", 0), "offline": stats.get("offline", 0), "alarm": stats.get("alarm", 0), "maintenance": stats.get("maintenance", 0)}
|
||||
|
||||
|
||||
@router.get("/{device_id}")
|
||||
async def get_device(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_device(data: DeviceCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
device = Device(**data.model_dump())
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
@router.put("/{device_id}")
|
||||
async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))):
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(device, k, v)
|
||||
return _device_to_dict(device)
|
||||
|
||||
|
||||
def _device_to_dict(d: Device) -> dict:
|
||||
return {
|
||||
"id": d.id, "name": d.name, "code": d.code, "device_type": d.device_type,
|
||||
"group_id": d.group_id, "model": d.model, "manufacturer": d.manufacturer,
|
||||
"serial_number": d.serial_number, "rated_power": d.rated_power,
|
||||
"location": d.location, "protocol": d.protocol, "collect_interval": d.collect_interval,
|
||||
"status": d.status, "is_active": d.is_active, "last_data_time": str(d.last_data_time) if d.last_data_time else None,
|
||||
}
|
||||
144
backend/app/api/v1/energy.py
Normal file
144
backend/app/api/v1/energy.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, text
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.energy import EnergyData, EnergyDailySummary
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/energy", tags=["能耗数据"])
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def query_history(
|
||||
device_id: int | None = None,
|
||||
data_type: str = "power",
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
granularity: str = Query("hour", pattern="^(raw|5min|hour|day)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""历史数据查询"""
|
||||
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
query = query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(EnergyData.timestamp <= end_time)
|
||||
|
||||
if granularity == "raw":
|
||||
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return [{"timestamp": str(d.timestamp), "value": d.value, "unit": d.unit, "device_id": d.device_id}
|
||||
for d in result.scalars().all()]
|
||||
else:
|
||||
if granularity == "5min":
|
||||
time_bucket = func.to_timestamp(
|
||||
func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300
|
||||
).label('time_bucket')
|
||||
elif granularity == "hour":
|
||||
time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket')
|
||||
else: # day
|
||||
time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket')
|
||||
agg_query = select(
|
||||
time_bucket,
|
||||
func.avg(EnergyData.value).label('avg_value'),
|
||||
func.max(EnergyData.value).label('max_value'),
|
||||
func.min(EnergyData.value).label('min_value'),
|
||||
).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_time)
|
||||
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
||||
result = await db.execute(agg_query)
|
||||
return [{"time": str(r[0]), "avg": round(r[1], 2), "max": round(r[2], 2), "min": round(r[3], 2)}
|
||||
for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/daily-summary")
|
||||
async def daily_summary(
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
energy_type: str | None = None,
|
||||
device_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""每日能耗汇总"""
|
||||
query = select(EnergyDailySummary)
|
||||
if start_date:
|
||||
query = query.where(EnergyDailySummary.date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(EnergyDailySummary.date <= end_date)
|
||||
if energy_type:
|
||||
query = query.where(EnergyDailySummary.energy_type == energy_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyDailySummary.device_id == device_id)
|
||||
query = query.order_by(EnergyDailySummary.date.desc()).limit(365)
|
||||
result = await db.execute(query)
|
||||
return [{
|
||||
"date": str(s.date), "device_id": s.device_id, "energy_type": s.energy_type,
|
||||
"consumption": s.total_consumption, "generation": s.total_generation,
|
||||
"peak_power": s.peak_power, "avg_power": s.avg_power,
|
||||
"operating_hours": s.operating_hours, "cost": s.cost, "carbon_emission": s.carbon_emission,
|
||||
} for s in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
async def energy_comparison(
|
||||
device_id: int | None = None,
|
||||
energy_type: str = "electricity",
|
||||
period: str = Query("month", pattern="^(day|week|month|year)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""能耗同比环比分析"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if period == "day":
|
||||
current_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(days=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "week":
|
||||
current_start = now - timedelta(days=now.weekday())
|
||||
current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start - timedelta(weeks=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
elif period == "month":
|
||||
current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = (current_start - timedelta(days=1)).replace(day=1)
|
||||
yoy_start = current_start.replace(year=current_start.year - 1)
|
||||
else:
|
||||
current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
prev_start = current_start.replace(year=current_start.year - 1)
|
||||
yoy_start = prev_start
|
||||
|
||||
async def sum_consumption(start, end):
|
||||
q = select(func.sum(EnergyDailySummary.total_consumption)).where(
|
||||
and_(EnergyDailySummary.date >= start, EnergyDailySummary.date < end,
|
||||
EnergyDailySummary.energy_type == energy_type)
|
||||
)
|
||||
if device_id:
|
||||
q = q.where(EnergyDailySummary.device_id == device_id)
|
||||
r = await db.execute(q)
|
||||
return r.scalar() or 0
|
||||
|
||||
current = await sum_consumption(current_start, now)
|
||||
previous = await sum_consumption(prev_start, current_start)
|
||||
yoy = await sum_consumption(yoy_start, yoy_start.replace(year=yoy_start.year + 1))
|
||||
|
||||
return {
|
||||
"current": round(current, 2),
|
||||
"previous": round(previous, 2),
|
||||
"yoy": round(yoy, 2),
|
||||
"mom_change": round((current - previous) / previous * 100, 1) if previous else 0,
|
||||
"yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0,
|
||||
}
|
||||
78
backend/app/api/v1/monitoring.py
Normal file
78
backend/app/api/v1/monitoring.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.device import Device
|
||||
from app.models.energy import EnergyData
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/monitoring", tags=["实时监控"])
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}/realtime")
|
||||
async def device_realtime(device_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""获取单台设备的最新实时数据"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
result = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.device_id == device_id, EnergyData.timestamp >= five_min_ago)
|
||||
).order_by(EnergyData.timestamp.desc()).limit(20)
|
||||
)
|
||||
data_points = result.scalars().all()
|
||||
latest = {}
|
||||
for d in data_points:
|
||||
if d.data_type not in latest:
|
||||
latest[d.data_type] = {"value": d.value, "unit": d.unit, "timestamp": str(d.timestamp)}
|
||||
|
||||
device_q = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = device_q.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"id": device.id, "name": device.name, "code": device.code,
|
||||
"device_type": device.device_type, "status": device.status,
|
||||
"model": device.model, "manufacturer": device.manufacturer,
|
||||
} if device else None,
|
||||
"data": latest,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/energy-flow")
|
||||
async def energy_flow(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""能流图数据 - 展示能量流向"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
# 获取各类设备最新功率
|
||||
result = await db.execute(
|
||||
select(Device.device_type, func.sum(EnergyData.value))
|
||||
.join(EnergyData, EnergyData.device_id == Device.id)
|
||||
.where(and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power"))
|
||||
.group_by(Device.device_type)
|
||||
)
|
||||
power_by_type = {row[0]: round(row[1], 2) for row in result.all()}
|
||||
|
||||
pv_power = power_by_type.get("pv_inverter", 0)
|
||||
hp_power = power_by_type.get("heat_pump", 0)
|
||||
total_load = hp_power + power_by_type.get("meter", 0)
|
||||
grid_import = max(0, total_load - pv_power)
|
||||
grid_export = max(0, pv_power - total_load)
|
||||
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "pv", "name": "光伏发电", "power": pv_power, "unit": "kW"},
|
||||
{"id": "grid", "name": "电网", "power": grid_import - grid_export, "unit": "kW"},
|
||||
{"id": "heatpump", "name": "热泵系统", "power": hp_power, "unit": "kW"},
|
||||
{"id": "building", "name": "建筑负荷", "power": total_load, "unit": "kW"},
|
||||
],
|
||||
"links": [
|
||||
{"source": "pv", "target": "building", "value": min(pv_power, total_load)},
|
||||
{"source": "pv", "target": "grid", "value": grid_export},
|
||||
{"source": "grid", "target": "building", "value": grid_import},
|
||||
{"source": "grid", "target": "heatpump", "value": hp_power},
|
||||
]
|
||||
}
|
||||
75
backend/app/api/v1/reports.py
Normal file
75
backend/app/api/v1/reports.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.report import ReportTemplate, ReportTask
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["报表管理"])
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
report_type: str
|
||||
description: str | None = None
|
||||
fields: list[dict]
|
||||
filters: dict | None = None
|
||||
aggregation: str = "sum"
|
||||
time_granularity: str = "hour"
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
template_id: int
|
||||
name: str | None = None
|
||||
schedule: str | None = None
|
||||
recipients: list[str] | None = None
|
||||
export_format: str = "xlsx"
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ReportTemplate).order_by(ReportTemplate.id))
|
||||
return [{
|
||||
"id": t.id, "name": t.name, "report_type": t.report_type, "description": t.description,
|
||||
"fields": t.fields, "is_system": t.is_system, "aggregation": t.aggregation,
|
||||
"time_granularity": t.time_granularity,
|
||||
} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(data: TemplateCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
template = ReportTemplate(**data.model_dump(), created_by=user.id)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
return {"id": template.id, "name": template.name}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ReportTask).order_by(ReportTask.id.desc()))
|
||||
return [{
|
||||
"id": t.id, "template_id": t.template_id, "name": t.name, "schedule": t.schedule,
|
||||
"status": t.status, "export_format": t.export_format, "file_path": t.file_path,
|
||||
"last_run": str(t.last_run) if t.last_run else None,
|
||||
} for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
task = ReportTask(**data.model_dump(), created_by=user.id)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
return {"id": task.id}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/run")
|
||||
async def run_task(task_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(ReportTask).where(ReportTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task.status = "running"
|
||||
# TODO: trigger Celery task
|
||||
return {"message": "报表生成中", "task_id": task.id}
|
||||
79
backend/app/api/v1/users.py
Normal file
79
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user, require_roles
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, Role
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
role: str = "visitor"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_roles("admin", "energy_manager")),
|
||||
):
|
||||
count_q = select(func.count(User.id))
|
||||
total = (await db.execute(count_q)).scalar()
|
||||
result = await db.execute(select(User).offset((page - 1) * page_size).limit(page_size).order_by(User.id))
|
||||
return {
|
||||
"total": total,
|
||||
"items": [{
|
||||
"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||
"email": u.email, "phone": u.phone, "role": u.role,
|
||||
"is_active": u.is_active, "last_login": str(u.last_login) if u.last_login else None,
|
||||
} for u in result.scalars().all()]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin"))):
|
||||
existing = await db.execute(select(User).where(User.username == data.username))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
new_user = User(
|
||||
username=data.username, hashed_password=hash_password(data.password),
|
||||
full_name=data.full_name, email=data.email, phone=data.phone, role=data.role,
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
return {"id": new_user.id, "username": new_user.username}
|
||||
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(user_id: int, data: UserUpdate, db: AsyncSession = Depends(get_db), admin: User = Depends(require_roles("admin"))):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
target = result.scalar_one_or_none()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(target, k, v)
|
||||
return {"message": "已更新"}
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_roles(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
result = await db.execute(select(Role).order_by(Role.id))
|
||||
return [{"id": r.id, "name": r.name, "display_name": r.display_name, "description": r.description}
|
||||
for r in result.scalars().all()]
|
||||
0
backend/app/collectors/__init__.py
Normal file
0
backend/app/collectors/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
27
backend/app/core/config.py
Normal file
27
backend/app/core/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "TianpuEMS"
|
||||
DEBUG: bool = True
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./tianpu_ems.db"
|
||||
DATABASE_URL_LOCAL: str = ""
|
||||
DATABASE_URL_SYNC: str = "sqlite:///./tianpu_ems.db"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
SECRET_KEY: str = "tianpu-ems-secret-key-change-in-production-2026"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
29
backend/app/core/database.py
Normal file
29
backend/app/core/database.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
db_url = settings.DATABASE_URL_LOCAL if settings.DATABASE_URL_LOCAL else settings.DATABASE_URL
|
||||
|
||||
engine_kwargs = {"echo": settings.DEBUG}
|
||||
if "sqlite" not in db_url:
|
||||
engine_kwargs["pool_size"] = 20
|
||||
engine_kwargs["max_overflow"] = 10
|
||||
|
||||
engine = create_async_engine(db_url, **engine_kwargs)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
34
backend/app/core/deps.py
Normal file
34
backend/app/core/deps.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
async def checker(user: User = Depends(get_current_user)):
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return user
|
||||
return checker
|
||||
29
backend/app/core/security.py
Normal file
29
backend/app/core/security.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
39
backend/app/main.py
Normal file
39
backend/app/main.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.services.simulator import DataSimulator
|
||||
|
||||
settings = get_settings()
|
||||
simulator = DataSimulator()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await simulator.start()
|
||||
yield
|
||||
await simulator.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="天普零碳园区智慧能源管理平台",
|
||||
description="Tianpu Zero-Carbon Park Smart Energy Management System",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "app": settings.APP_NAME}
|
||||
15
backend/app/models/__init__.py
Normal file
15
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.models.user import User, Role, AuditLog
|
||||
from app.models.device import Device, DeviceGroup, DeviceType
|
||||
from app.models.energy import EnergyData, EnergyDailySummary
|
||||
from app.models.alarm import AlarmRule, AlarmEvent
|
||||
from app.models.carbon import CarbonEmission, EmissionFactor
|
||||
from app.models.report import ReportTemplate, ReportTask
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "AuditLog",
|
||||
"Device", "DeviceGroup", "DeviceType",
|
||||
"EnergyData", "EnergyDailySummary",
|
||||
"AlarmRule", "AlarmEvent",
|
||||
"CarbonEmission", "EmissionFactor",
|
||||
"ReportTemplate", "ReportTask",
|
||||
]
|
||||
48
backend/app/models/alarm.py
Normal file
48
backend/app/models/alarm.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AlarmRule(Base):
|
||||
__tablename__ = "alarm_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
device_id = Column(Integer, ForeignKey("devices.id"))
|
||||
device_type = Column(String(50)) # 按设备类型的通用规则
|
||||
data_type = Column(String(50), nullable=False) # 监控的数据类型
|
||||
condition = Column(String(20), nullable=False) # gt, lt, eq, neq, range_out, rate_of_change
|
||||
threshold = Column(Float)
|
||||
threshold_high = Column(Float) # 范围上限
|
||||
threshold_low = Column(Float) # 范围下限
|
||||
duration = Column(Integer, default=0) # 持续时间(秒)
|
||||
severity = Column(String(20), default="warning") # critical, major, warning
|
||||
notify_channels = Column(JSON) # ["sms", "email", "app", "wechat"]
|
||||
notify_targets = Column(JSON) # 通知对象
|
||||
auto_action = Column(JSON) # 联动动作
|
||||
silence_start = Column(String(10)) # 静默开始时间 HH:MM
|
||||
silence_end = Column(String(10)) # 静默结束时间
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AlarmEvent(Base):
|
||||
__tablename__ = "alarm_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
rule_id = Column(Integer, ForeignKey("alarm_rules.id"))
|
||||
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False)
|
||||
severity = Column(String(20), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
value = Column(Float) # 触发时的数值
|
||||
threshold = Column(Float) # 阈值
|
||||
status = Column(String(20), default="active") # active, acknowledged, resolved
|
||||
acknowledged_by = Column(Integer, ForeignKey("users.id"))
|
||||
acknowledged_at = Column(DateTime(timezone=True))
|
||||
resolved_at = Column(DateTime(timezone=True))
|
||||
resolve_note = Column(Text)
|
||||
triggered_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
36
backend/app/models/carbon.py
Normal file
36
backend/app/models/carbon.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EmissionFactor(Base):
|
||||
"""碳排放因子"""
|
||||
__tablename__ = "emission_factors"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
energy_type = Column(String(50), nullable=False) # electricity, natural_gas, diesel, etc.
|
||||
factor = Column(Float, nullable=False) # kgCO2/单位
|
||||
unit = Column(String(20), nullable=False) # kWh, m³, L, etc.
|
||||
region = Column(String(50), default="north_china") # 区域电网
|
||||
scope = Column(Integer, nullable=False) # 1, 2, 3
|
||||
source = Column(String(200)) # 数据来源
|
||||
year = Column(Integer)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CarbonEmission(Base):
|
||||
"""碳排放记录"""
|
||||
__tablename__ = "carbon_emissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
scope = Column(Integer, nullable=False) # 1, 2, 3
|
||||
category = Column(String(50), nullable=False) # electricity, gas, heat, etc.
|
||||
emission = Column(Float, nullable=False) # kgCO2e
|
||||
reduction = Column(Float, default=0) # 减排量 kgCO2e (光伏、热泵节能等)
|
||||
energy_consumption = Column(Float) # 对应能耗量
|
||||
energy_unit = Column(String(20))
|
||||
emission_factor_id = Column(Integer)
|
||||
note = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
50
backend/app/models/device.py
Normal file
50
backend/app/models/device.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DeviceType(Base):
|
||||
__tablename__ = "device_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(50), unique=True, nullable=False) # pv_inverter, heat_pump, solar_thermal, battery, meter, sensor
|
||||
name = Column(String(100), nullable=False)
|
||||
icon = Column(String(100))
|
||||
data_fields = Column(JSON) # 该类型设备的数据字段定义
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DeviceGroup(Base):
|
||||
__tablename__ = "device_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("device_groups.id"))
|
||||
location = Column(String(200))
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(100), unique=True, nullable=False) # 设备编号
|
||||
device_type = Column(String(50), ForeignKey("device_types.code"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("device_groups.id"))
|
||||
model = Column(String(100)) # 型号
|
||||
manufacturer = Column(String(100)) # 厂商
|
||||
serial_number = Column(String(100)) # 序列号
|
||||
rated_power = Column(Float) # 额定功率 kW
|
||||
install_date = Column(DateTime(timezone=True))
|
||||
location = Column(String(200))
|
||||
protocol = Column(String(50)) # modbus_tcp, modbus_rtu, opc_ua, mqtt, http_api
|
||||
connection_params = Column(JSON) # 连接参数 (IP, port, slave_id, etc.)
|
||||
collect_interval = Column(Integer, default=15) # 采集间隔(秒)
|
||||
status = Column(String(20), default="offline") # online, offline, alarm, maintenance
|
||||
is_active = Column(Boolean, default=True)
|
||||
metadata_ = Column("metadata", JSON) # 扩展元数据
|
||||
last_data_time = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
38
backend/app/models/energy.py
Normal file
38
backend/app/models/energy.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EnergyData(Base):
|
||||
"""时序能耗采集数据 - 使用TimescaleDB hypertable"""
|
||||
__tablename__ = "energy_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
data_type = Column(String(50), nullable=False) # power, energy, temperature, flow, etc.
|
||||
value = Column(Float, nullable=False)
|
||||
unit = Column(String(20)) # kW, kWh, ℃, m³/h, etc.
|
||||
quality = Column(Integer, default=0) # 0=good, 1=interpolated, 2=suspect
|
||||
raw_data = Column(JSON) # 原始完整数据包
|
||||
|
||||
|
||||
class EnergyDailySummary(Base):
|
||||
"""每日能耗汇总"""
|
||||
__tablename__ = "energy_daily_summary"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
energy_type = Column(String(50), nullable=False) # electricity, heat, water, gas
|
||||
total_consumption = Column(Float, default=0) # 总消耗
|
||||
total_generation = Column(Float, default=0) # 总产出
|
||||
peak_power = Column(Float) # 最大功率
|
||||
min_power = Column(Float) # 最小功率
|
||||
avg_power = Column(Float) # 平均功率
|
||||
operating_hours = Column(Float) # 运行小时数
|
||||
avg_cop = Column(Float) # 平均COP (热泵)
|
||||
avg_temperature = Column(Float) # 平均温度
|
||||
cost = Column(Float) # 费用
|
||||
carbon_emission = Column(Float) # 碳排放 kgCO2
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
38
backend/app/models/report.py
Normal file
38
backend/app/models/report.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ReportTemplate(Base):
|
||||
__tablename__ = "report_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
report_type = Column(String(50), nullable=False) # daily, weekly, monthly, yearly, custom
|
||||
description = Column(Text)
|
||||
fields = Column(JSON, nullable=False) # 报表字段配置
|
||||
filters = Column(JSON) # 默认筛选条件
|
||||
aggregation = Column(String(20), default="sum") # sum, avg, max, min
|
||||
time_granularity = Column(String(20), default="hour") # hour, day, month
|
||||
format_config = Column(JSON) # 展示格式配置
|
||||
is_system = Column(Boolean, default=False) # 系统预置
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ReportTask(Base):
|
||||
__tablename__ = "report_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey("report_templates.id"), nullable=False)
|
||||
name = Column(String(200))
|
||||
schedule = Column(String(50)) # cron expression or null for manual
|
||||
next_run = Column(DateTime(timezone=True))
|
||||
last_run = Column(DateTime(timezone=True))
|
||||
recipients = Column(JSON) # 接收人
|
||||
export_format = Column(String(20), default="xlsx") # xlsx, csv, pdf
|
||||
file_path = Column(String(500)) # 最新生成的文件路径
|
||||
status = Column(String(20), default="pending") # pending, running, completed, failed
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
42
backend/app/models/user.py
Normal file
42
backend/app/models/user.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), unique=True, nullable=False) # admin, energy_manager, area_manager, operator, analyst, visitor
|
||||
display_name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
permissions = Column(Text) # JSON string of permission list
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String(100), unique=True)
|
||||
hashed_password = Column(String(200), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
phone = Column(String(20))
|
||||
role = Column(String(50), ForeignKey("roles.name"), default="visitor")
|
||||
is_active = Column(Boolean, default=True)
|
||||
last_login = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
action = Column(String(50), nullable=False)
|
||||
resource = Column(String(100))
|
||||
detail = Column(Text)
|
||||
ip_address = Column(String(50))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
144
backend/app/services/simulator.py
Normal file
144
backend/app/services/simulator.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据"""
|
||||
import asyncio
|
||||
import random
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
|
||||
class DataSimulator:
|
||||
def __init__(self):
|
||||
self._task = None
|
||||
self._running = False
|
||||
|
||||
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:
|
||||
print(f"Simulator error: {e}")
|
||||
await asyncio.sleep(15) # 每15秒生成一次
|
||||
|
||||
async def _generate_data(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
hour = (now.hour + 8) % 24 # 北京时间
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Device).where(Device.is_active == True))
|
||||
devices = result.scalars().all()
|
||||
|
||||
data_points = []
|
||||
for device in devices:
|
||||
points = self._generate_device_data(device, now, hour)
|
||||
data_points.extend(points)
|
||||
device.status = "online"
|
||||
device.last_data_time = now
|
||||
|
||||
if data_points:
|
||||
session.add_all(data_points)
|
||||
await session.commit()
|
||||
|
||||
def _generate_device_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
|
||||
points = []
|
||||
if device.device_type == "pv_inverter":
|
||||
points = self._gen_pv_data(device, now, hour)
|
||||
elif device.device_type == "heat_pump":
|
||||
points = self._gen_heatpump_data(device, now, hour)
|
||||
elif device.device_type == "meter":
|
||||
points = self._gen_meter_data(device, now, hour)
|
||||
elif device.device_type == "sensor":
|
||||
points = self._gen_sensor_data(device, now, hour)
|
||||
return points
|
||||
|
||||
def _gen_pv_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
|
||||
"""光伏逆变器数据 - 基于日照模型"""
|
||||
rated = device.rated_power or 110 # kW
|
||||
# 日照模型: 6-18点有发电, 12点最大
|
||||
if 6 <= hour <= 18:
|
||||
solar_factor = math.sin(math.pi * (hour - 6) / 12)
|
||||
weather_factor = random.uniform(0.6, 1.0) # 天气影响
|
||||
power = rated * solar_factor * weather_factor * random.uniform(0.85, 0.95)
|
||||
else:
|
||||
power = 0
|
||||
|
||||
daily_energy = rated * 4.5 * random.uniform(0.8, 1.1) # 日发电量约4.5等效小时
|
||||
cumulative_energy = 170 + random.uniform(0, 5) # 累计发电MWh
|
||||
|
||||
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(daily_energy * hour / 24, 2), unit="kWh"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="total_energy", value=round(cumulative_energy * 1000, 1), unit="kWh"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="dc_voltage", value=round(random.uniform(250, 800), 1), unit="V"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="ac_voltage", value=round(random.uniform(218, 222), 1), unit="V"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=round(random.uniform(25, 45), 1), unit="℃"),
|
||||
]
|
||||
|
||||
def _gen_heatpump_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
|
||||
"""热泵机组数据"""
|
||||
# 冬季供暖模式
|
||||
is_heating_season = now.month in [1, 2, 3, 10, 11, 12]
|
||||
if is_heating_season:
|
||||
outdoor_temp = random.uniform(-5, 10)
|
||||
cop = random.uniform(2.5, 3.8)
|
||||
inlet_temp = random.uniform(35, 42)
|
||||
outlet_temp = inlet_temp + random.uniform(5, 10)
|
||||
power = random.uniform(20, 35)
|
||||
flow_rate = random.uniform(8, 15)
|
||||
else:
|
||||
outdoor_temp = random.uniform(15, 35)
|
||||
cop = random.uniform(3.5, 5.0)
|
||||
inlet_temp = random.uniform(8, 12)
|
||||
outlet_temp = inlet_temp - random.uniform(3, 6)
|
||||
power = random.uniform(15, 28)
|
||||
flow_rate = random.uniform(8, 15)
|
||||
|
||||
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="cop", value=round(cop, 2), unit=""),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="inlet_temp", value=round(inlet_temp, 1), unit="℃"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="outlet_temp", value=round(outlet_temp, 1), unit="℃"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", value=round(flow_rate, 1), unit="m³/h"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="outdoor_temp", value=round(outdoor_temp, 1), unit="℃"),
|
||||
]
|
||||
|
||||
def _gen_meter_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
|
||||
"""电表数据"""
|
||||
# 负荷曲线: 白天高, 夜间低
|
||||
base_load = 50 # 基础负荷kW
|
||||
if 8 <= hour <= 18:
|
||||
load_factor = random.uniform(1.2, 2.0)
|
||||
elif 18 <= hour <= 22:
|
||||
load_factor = random.uniform(0.8, 1.3)
|
||||
else:
|
||||
load_factor = random.uniform(0.3, 0.6)
|
||||
|
||||
power = base_load * load_factor + random.uniform(-5, 5)
|
||||
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="voltage", value=round(random.uniform(218, 225), 1), unit="V"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="current", value=round(power / 0.38 / random.uniform(0.85, 0.95), 1), unit="A"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="power_factor", value=round(random.uniform(0.88, 0.98), 3), unit=""),
|
||||
]
|
||||
|
||||
def _gen_sensor_data(self, device: Device, now: datetime, hour: int) -> list[EnergyData]:
|
||||
"""温湿度传感器数据"""
|
||||
# 室内温度根据供暖状态
|
||||
indoor_temp = random.uniform(20, 24) if now.month in [1, 2, 3, 10, 11, 12] else random.uniform(22, 28)
|
||||
humidity = random.uniform(35, 65)
|
||||
return [
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=round(indoor_temp, 1), unit="℃"),
|
||||
EnergyData(device_id=device.id, timestamp=now, data_type="humidity", value=round(humidity, 1), unit="%"),
|
||||
]
|
||||
0
backend/app/tasks/__init__.py
Normal file
0
backend/app/tasks/__init__.py
Normal file
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.18
|
||||
redis==5.2.1
|
||||
celery==5.4.0
|
||||
httpx==0.28.1
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
apscheduler==3.10.4
|
||||
Reference in New Issue
Block a user