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:
Du Wenbo
2026-04-01 13:36:06 +08:00
commit f53a610a19
77 changed files with 8904 additions and 0 deletions

9
backend/.env Normal file
View 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
View 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
View 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
View 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()

View 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
View File

View File

14
backend/app/api/router.py Normal file
View 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)

View File

View 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,
}

View 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,
}

View 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()]

View 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()]

View 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,
}

View 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,
}

View 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},
]
}

View 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}

View 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()]

View File

View File

View 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()

View 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
View 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

View 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
View 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}

View 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",
]

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View File

View 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="%"),
]

View File

18
backend/requirements.txt Normal file
View 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