feat!: generic defaults, dashboard fallback, PV filter fix (v1.3.0)

- Replace all hardcoded Tianpu/天普 defaults with generic "EMS Platform"
- Add energy_today fallback: query raw energy_data when daily summary empty
- Fix PV device filter to include sungrow_inverter device type
- Update APP_NAME, CUSTOMER default, SECRET_KEY, SMTP, Celery, email templates

BREAKING: CUSTOMER default changed from "tianpu" to "default"
Existing deployments with CUSTOMER=tianpu in .env are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-06 22:02:38 +08:00
parent 1636dea8f1
commit 19d0795c23
15 changed files with 61 additions and 29 deletions

View File

@@ -1 +1 @@
1.2.0 1.3.0

View File

@@ -1,6 +1,6 @@
{ {
"project": "ems-core", "project": "ems-core",
"project_version": "1.2.0", "project_version": "1.3.0",
"last_updated": "2026-04-06", "last_updated": "2026-04-06",
"notes": "Backend-only architecture, frontend removed to customer repos" "notes": "Generic defaults, dashboard energy fallback, PV device type filter fix"
} }

View File

@@ -1,6 +1,6 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
sqlalchemy.url = postgresql://tianpu:tianpu2026@localhost:5432/tianpu_ems sqlalchemy.url = postgresql://ems:ems2026@localhost:5432/ems
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic

View File

@@ -26,7 +26,7 @@ def upgrade() -> None:
# Seed default settings # Seed default settings
op.execute(""" op.execute("""
INSERT INTO system_settings (key, value, description) VALUES INSERT INTO system_settings (key, value, description) VALUES
('platform_name', '天普零碳园区智慧能源管理平台', '平台名称'), ('platform_name', 'Smart Energy Management Platform', '平台名称'),
('data_retention_days', '365', '数据保留天数'), ('data_retention_days', '365', '数据保留天数'),
('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'), ('alarm_auto_resolve_minutes', '30', '告警自动解除时间(分钟)'),
('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'), ('simulator_interval_seconds', '15', '模拟器采集间隔(秒)'),

View File

@@ -26,7 +26,7 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
) )
device_stats = {row[0]: row[1] for row in device_stats_q.all()} device_stats = {row[0]: row[1] for row in device_stats_q.all()}
# 今日能耗汇总 # 今日能耗汇总 (from daily summary table)
daily_q = await db.execute( daily_q = await db.execute(
select( select(
EnergyDailySummary.energy_type, EnergyDailySummary.energy_type,
@@ -38,6 +38,35 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
for row in daily_q.all(): for row in daily_q.all():
energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0} energy_summary[row[0]] = {"consumption": row[1] or 0, "generation": row[2] or 0}
# Fallback: if daily summary is empty, compute from raw energy_data
if not energy_summary:
from sqlalchemy import distinct
fallback_q = await db.execute(
select(
func.sum(EnergyData.value),
).where(
and_(
EnergyData.timestamp >= today_start,
EnergyData.data_type == "daily_energy",
)
).group_by(EnergyData.device_id).order_by(EnergyData.device_id)
)
# Get the latest daily_energy per device (avoid double-counting)
latest_energy_q = await db.execute(
select(
EnergyData.device_id,
func.max(EnergyData.value).label("max_energy"),
).where(
and_(
EnergyData.timestamp >= today_start,
EnergyData.data_type == "daily_energy",
)
).group_by(EnergyData.device_id)
)
total_gen = sum(row[1] or 0 for row in latest_energy_q.all())
if total_gen > 0:
energy_summary["electricity"] = {"consumption": 0, "generation": round(total_gen, 2)}
# 今日碳排放 # 今日碳排放
carbon_q = await db.execute( carbon_q = await db.execute(
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction)) select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
@@ -134,7 +163,10 @@ async def get_load_curve(
async def _get_pv_device_ids(db: AsyncSession): async def _get_pv_device_ids(db: AsyncSession):
result = await db.execute( result = await db.execute(
select(Device.id).where(Device.device_type == "pv_inverter", Device.is_active == True) select(Device.id).where(
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
Device.is_active == True,
)
) )
return [r[0] for r in result.fetchall()] return [r[0] for r in result.fetchall()]

View File

@@ -12,7 +12,7 @@ router = APIRouter(prefix="/settings", tags=["系统设置"])
# Default settings — used when keys are missing from DB # Default settings — used when keys are missing from DB
DEFAULTS: dict[str, str] = { DEFAULTS: dict[str, str] = {
"platform_name": "天普零碳园区智慧能源管理平台", "platform_name": "Smart Energy Management Platform",
"data_retention_days": "365", "data_retention_days": "365",
"alarm_auto_resolve_minutes": "30", "alarm_auto_resolve_minutes": "30",
"simulator_interval_seconds": "15", "simulator_interval_seconds": "15",

View File

@@ -6,23 +6,23 @@ import yaml
class Settings(BaseSettings): class Settings(BaseSettings):
APP_NAME: str = "TianpuEMS" APP_NAME: str = "EMS Platform"
DEBUG: bool = True DEBUG: bool = True
API_V1_PREFIX: str = "/api/v1" API_V1_PREFIX: str = "/api/v1"
# Customer configuration # Customer configuration
CUSTOMER: str = "tianpu" # tianpu, zpark, etc. CUSTOMER: str = "default" # tianpu, zpark, etc.
CUSTOMER_DISPLAY_NAME: str = "" # Loaded from customer config CUSTOMER_DISPLAY_NAME: str = "" # Loaded from customer config
# Database: set DATABASE_URL in .env to override. # Database: set DATABASE_URL in .env to override.
# Default: SQLite for local dev. Docker sets PostgreSQL via env var. # Default: SQLite for local dev. Docker sets PostgreSQL via env var.
# Examples: # Examples:
# SQLite: sqlite+aiosqlite:///./tianpu_ems.db # SQLite: sqlite+aiosqlite:///./ems.db
# PostgreSQL: postgresql+asyncpg://tianpu:tianpu2026@localhost:5432/tianpu_ems # PostgreSQL: postgresql+asyncpg://ems:ems2026@localhost:5432/ems
DATABASE_URL: str = "sqlite+aiosqlite:///./tianpu_ems.db" DATABASE_URL: str = "sqlite+aiosqlite:///./ems.db"
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
SECRET_KEY: str = "tianpu-ems-secret-key-change-in-production-2026" SECRET_KEY: str = "ems-secret-key-change-in-production-2026"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
@@ -40,7 +40,7 @@ class Settings(BaseSettings):
SMTP_PORT: int = 587 SMTP_PORT: int = 587
SMTP_USER: str = "" SMTP_USER: str = ""
SMTP_PASSWORD: str = "" SMTP_PASSWORD: str = ""
SMTP_FROM: str = "noreply@tianpu-ems.com" SMTP_FROM: str = "noreply@ems-platform.com"
SMTP_ENABLED: bool = False SMTP_ENABLED: bool = False
# Platform URL for links in emails # Platform URL for links in emails

View File

@@ -84,8 +84,8 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title=customer_config.get("platform_name", "天普零碳园区智慧能源管理平台"), title=customer_config.get("platform_name", "Smart Energy Management Platform"),
description=customer_config.get("platform_name_en", "Tianpu Zero-Carbon Park Smart Energy Management System"), description=customer_config.get("platform_name_en", "Smart Energy Management System"),
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )

View File

@@ -104,7 +104,7 @@ async def _send_alarm_email(
platform_url=settings.PLATFORM_URL, platform_url=settings.PLATFORM_URL,
) )
subject = f"[{severity_cfg['label']}] {event.title} - 天普EMS告警通知" subject = f"[{severity_cfg['label']}] {event.title} - EMS Alarm Notification"
asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html)) asyncio.create_task(send_email(to=emails, subject=subject, body_html=body_html))
# Rate limit: don't create duplicate events for the same rule+device within this window # Rate limit: don't create duplicate events for the same rule+device within this window

View File

@@ -1,5 +1,5 @@
""" """
报表生成服务 - PDF/Excel report generation for Tianpu EMS. 报表生成服务 - PDF/Excel report generation for EMS Platform.
""" """
import os import os
import io import io
@@ -18,7 +18,7 @@ from app.models.carbon import CarbonEmission
REPORTS_DIR = Path(__file__).resolve().parent.parent.parent / "reports" REPORTS_DIR = Path(__file__).resolve().parent.parent.parent / "reports"
REPORTS_DIR.mkdir(exist_ok=True) REPORTS_DIR.mkdir(exist_ok=True)
PLATFORM_TITLE = "天普零碳园区智慧能源管理平台" PLATFORM_TITLE = "Smart Energy Management Platform"
ENERGY_TYPE_LABELS = { ENERGY_TYPE_LABELS = {
"electricity": "电力", "electricity": "电力",

View File

@@ -103,10 +103,10 @@ async def _run_report_task(task_id: int):
recipients = task.recipients or [] recipients = task.recipients or []
if isinstance(recipients, list) and recipients: if isinstance(recipients, list) and recipients:
report_name = task.name or template.name report_name = task.name or template.name
subject = f"{report_name} - 天普EMS自动报表" subject = f"{report_name} - EMS Auto Report"
body_html = f""" body_html = f"""
<div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;"> <div style="font-family: 'Microsoft YaHei', sans-serif; padding: 20px;">
<h2 style="color: #1a73e8;">天普零碳园区智慧能源管理平台</h2> <h2 style="color: #1a73e8;">Smart Energy Management Platform</h2>
<p>您好,</p> <p>您好,</p>
<p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p> <p>系统已自动生成 <strong>{report_name}</strong>,请查收附件。</p>
<p style="color: #666; font-size: 13px;"> <p style="color: #666; font-size: 13px;">

View File

@@ -1,4 +1,4 @@
"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据 """模拟数据生成器 - 为园区设备生成真实感的模拟数据
Uses physics-based solar position, Beijing weather models, cloud transients, Uses physics-based solar position, Beijing weather models, cloud transients,
temperature derating, and realistic building load patterns to produce data temperature derating, and realistic building load patterns to produce data

View File

@@ -3,7 +3,7 @@
Shared by both the real-time simulator and the backfill script. Shared by both the real-time simulator and the backfill script.
Deterministic when given a seed — call set_seed() for reproducible backfills. Deterministic when given a seed — call set_seed() for reproducible backfills.
Tianpu campus: 39.9N, 116.4E (Beijing / Daxing district) Default campus: 39.9N, 116.4E (Beijing / Daxing district)
""" """
import math import math
@@ -87,7 +87,7 @@ def solar_altitude(dt: datetime) -> float:
# Local solar time # Local solar time
beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt beijing_dt = dt + timedelta(hours=BEIJING_TZ_OFFSET) if dt.tzinfo else dt
# Standard meridian for UTC+8 is 120E; Tianpu is at 116.4E # Standard meridian for UTC+8 is 120E; default campus is at 116.4E
time_offset = _equation_of_time(doy) + 4 * (BEIJING_LON - 120.0) time_offset = _equation_of_time(doy) + 4 * (BEIJING_LON - 120.0)
solar_hour = beijing_dt.hour + beijing_dt.minute / 60.0 + beijing_dt.second / 3600.0 solar_hour = beijing_dt.hour + beijing_dt.minute / 60.0 + beijing_dt.second / 3600.0
solar_hour += time_offset / 60.0 solar_hour += time_offset / 60.0

View File

@@ -4,7 +4,7 @@ from app.core.config import get_settings
settings = get_settings() settings = get_settings()
celery_app = Celery( celery_app = Celery(
"tianpu_ems", "ems_platform",
broker=settings.REDIS_URL, broker=settings.REDIS_URL,
backend=settings.REDIS_URL, backend=settings.REDIS_URL,
) )

View File

@@ -13,8 +13,8 @@
<!-- Header --> <!-- Header -->
<tr> <tr>
<td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;"> <td style="background: linear-gradient(135deg, #1a73e8, #0d47a1); padding:24px 32px; text-align:center;">
<div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">TIANPU EMS</div> <div style="font-size:12px; color:rgba(255,255,255,0.8); margin-bottom:4px;">EMS PLATFORM</div>
<div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">天普零碳园区智慧能源管理平台</div> <div style="font-size:20px; font-weight:bold; color:#ffffff; letter-spacing:1px;">Smart Energy Management Platform</div>
</td> </td>
</tr> </tr>
@@ -85,7 +85,7 @@
<td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;"> <td style="background-color:#f8f9fa; padding:16px 32px; border-top:1px solid #e8e8e8;">
<div style="font-size:12px; color:#999; text-align:center; line-height:1.6;"> <div style="font-size:12px; color:#999; text-align:center; line-height:1.6;">
此为系统自动发送,请勿回复。<br> 此为系统自动发送,请勿回复。<br>
天普零碳园区智慧能源管理平台 &copy; 2026 Smart Energy Management Platform &copy; 2026
</div> </div>
</td> </td>
</tr> </tr>