fix: BigScreen data display, carbon, energy history (v1.6.2)
BigScreen fixes: - Fix NaN in 今日用电 (normalize energy_today structure) - Fix 总设备=0 (compute from online+offline) - Fix energy flow zeros (map total_load→total_power) - Fix 今日发电=0 (extract from nested energy_today) Backend fixes (synced from ems-core): - Carbon overview fallback from energy_data × emission_factors - Energy history: datetime parsing (was 500) - Dashboard generation: station-level dedup (93K→14.8K kWh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,10 @@ class ReportGenerate(BaseModel):
|
|||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
"""碳排放总览"""
|
"""碳排放总览 - 优先从carbon_emissions表读取,为空时从energy_data实时计算"""
|
||||||
|
from app.models.energy import EnergyData
|
||||||
|
from app.models.device import Device
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
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)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
@@ -70,6 +73,52 @@ async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depen
|
|||||||
month = await sum_carbon(month_start, now)
|
month = await sum_carbon(month_start, now)
|
||||||
year = await sum_carbon(year_start, now)
|
year = await sum_carbon(year_start, now)
|
||||||
|
|
||||||
|
# Fallback: if carbon_emissions is empty, compute reduction from PV generation
|
||||||
|
has_carbon_data = (today["emission"] + today["reduction"] +
|
||||||
|
month["emission"] + month["reduction"] +
|
||||||
|
year["emission"] + year["reduction"]) > 0
|
||||||
|
|
||||||
|
if not has_carbon_data:
|
||||||
|
# Get grid emission factor (华北电网 0.582 kgCO2/kWh)
|
||||||
|
factor_q = await db.execute(
|
||||||
|
select(EmissionFactor.factor).where(
|
||||||
|
EmissionFactor.energy_type == "electricity"
|
||||||
|
).order_by(EmissionFactor.id).limit(1)
|
||||||
|
)
|
||||||
|
grid_factor = factor_q.scalar() or 0.582 # default fallback
|
||||||
|
|
||||||
|
# Compute PV generation from energy_data using latest daily_energy per station
|
||||||
|
# Device names like AP1xx belong to station 1, AP2xx to station 2
|
||||||
|
# To avoid double-counting station-level data written to multiple devices,
|
||||||
|
# we group by station prefix (first 3 chars of device name) and take MAX
|
||||||
|
async def compute_pv_reduction(start, end):
|
||||||
|
q = await db.execute(
|
||||||
|
select(
|
||||||
|
func.substring(Device.name, text("1"), text("3")).label("station"),
|
||||||
|
func.max(EnergyData.value).label("max_energy"),
|
||||||
|
).select_from(EnergyData).join(
|
||||||
|
Device, EnergyData.device_id == Device.id
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
EnergyData.timestamp >= start,
|
||||||
|
EnergyData.timestamp < end,
|
||||||
|
EnergyData.data_type == "daily_energy",
|
||||||
|
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||||
|
)
|
||||||
|
).group_by(text("station"))
|
||||||
|
)
|
||||||
|
total_kwh = sum(row[1] or 0 for row in q.all())
|
||||||
|
# Carbon reduction (kg CO2) = generation (kWh) * grid emission factor
|
||||||
|
return round(total_kwh * grid_factor / 1000, 4) # convert to tons
|
||||||
|
|
||||||
|
today_reduction = await compute_pv_reduction(today_start, now)
|
||||||
|
month_reduction = await compute_pv_reduction(month_start, now)
|
||||||
|
year_reduction = await compute_pv_reduction(year_start, now)
|
||||||
|
|
||||||
|
today = {"emission": 0, "reduction": today_reduction}
|
||||||
|
month = {"emission": 0, "reduction": month_reduction}
|
||||||
|
year = {"emission": 0, "reduction": year_reduction}
|
||||||
|
|
||||||
# 各scope分布
|
# 各scope分布
|
||||||
scope_q = await db.execute(
|
scope_q = await db.execute(
|
||||||
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
||||||
|
|||||||
@@ -40,28 +40,24 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
|||||||
|
|
||||||
# Fallback: if daily summary is empty, compute from raw energy_data
|
# Fallback: if daily summary is empty, compute from raw energy_data
|
||||||
if not energy_summary:
|
if not energy_summary:
|
||||||
from sqlalchemy import distinct
|
# Get the latest daily_energy per station (avoid double-counting).
|
||||||
fallback_q = await db.execute(
|
# The collector writes station-level daily_energy to individual device rows,
|
||||||
select(
|
# so multiple devices from the same station share the same value.
|
||||||
func.sum(EnergyData.value),
|
# Group by station prefix (first 3 chars of device name, e.g. "AP1", "AP2")
|
||||||
).where(
|
# and take MAX per station to deduplicate.
|
||||||
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(
|
latest_energy_q = await db.execute(
|
||||||
select(
|
select(
|
||||||
EnergyData.device_id,
|
func.substring(Device.name, text("1"), text("3")).label("station"),
|
||||||
func.max(EnergyData.value).label("max_energy"),
|
func.max(EnergyData.value).label("max_energy"),
|
||||||
|
).select_from(EnergyData).join(
|
||||||
|
Device, EnergyData.device_id == Device.id
|
||||||
).where(
|
).where(
|
||||||
and_(
|
and_(
|
||||||
EnergyData.timestamp >= today_start,
|
EnergyData.timestamp >= today_start,
|
||||||
EnergyData.data_type == "daily_energy",
|
EnergyData.data_type == "daily_energy",
|
||||||
|
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||||
)
|
)
|
||||||
).group_by(EnergyData.device_id)
|
).group_by(text("station"))
|
||||||
)
|
)
|
||||||
total_gen = sum(row[1] or 0 for row in latest_energy_q.all())
|
total_gen = sum(row[1] or 0 for row in latest_energy_q.all())
|
||||||
if total_gen > 0:
|
if total_gen > 0:
|
||||||
@@ -111,7 +107,7 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
|||||||
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
"""实时功率数据 - 获取最近的采集数据"""
|
"""实时功率数据 - 获取最近的采集数据"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
five_min_ago = now - timedelta(minutes=5)
|
five_min_ago = now - timedelta(minutes=20)
|
||||||
|
|
||||||
latest_q = await db.execute(
|
latest_q = await db.execute(
|
||||||
select(EnergyData).where(
|
select(EnergyData).where(
|
||||||
|
|||||||
@@ -32,13 +32,27 @@ async def query_history(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""历史数据查询"""
|
"""历史数据查询"""
|
||||||
|
# Parse time strings to datetime for proper PostgreSQL timestamp comparison
|
||||||
|
start_dt = None
|
||||||
|
end_dt = None
|
||||||
|
if start_time:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(start_time)
|
||||||
|
except ValueError:
|
||||||
|
start_dt = datetime.strptime(start_time, "%Y-%m-%d")
|
||||||
|
if end_time:
|
||||||
|
try:
|
||||||
|
end_dt = datetime.fromisoformat(end_time)
|
||||||
|
except ValueError:
|
||||||
|
end_dt = datetime.strptime(end_time, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||||
|
|
||||||
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
||||||
if device_id:
|
if device_id:
|
||||||
query = query.where(EnergyData.device_id == device_id)
|
query = query.where(EnergyData.device_id == device_id)
|
||||||
if start_time:
|
if start_dt:
|
||||||
query = query.where(EnergyData.timestamp >= start_time)
|
query = query.where(EnergyData.timestamp >= start_dt)
|
||||||
if end_time:
|
if end_dt:
|
||||||
query = query.where(EnergyData.timestamp <= end_time)
|
query = query.where(EnergyData.timestamp <= end_dt)
|
||||||
|
|
||||||
if granularity == "raw":
|
if granularity == "raw":
|
||||||
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
@@ -74,10 +88,10 @@ async def query_history(
|
|||||||
).where(EnergyData.data_type == data_type)
|
).where(EnergyData.data_type == data_type)
|
||||||
if device_id:
|
if device_id:
|
||||||
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
||||||
if start_time:
|
if start_dt:
|
||||||
agg_query = agg_query.where(EnergyData.timestamp >= start_time)
|
agg_query = agg_query.where(EnergyData.timestamp >= start_dt)
|
||||||
if end_time:
|
if end_dt:
|
||||||
agg_query = agg_query.where(EnergyData.timestamp <= end_time)
|
agg_query = agg_query.where(EnergyData.timestamp <= end_dt)
|
||||||
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
||||||
result = await db.execute(agg_query)
|
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)}
|
return [{"time": str(r[0]), "avg": round(r[1], 2), "max": round(r[2], 2), "min": round(r[3], 2)}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export default function EnergyFlowDiagram({ realtime, overview }: Props) {
|
|||||||
const particlesRef = useRef<Particle[]>([]);
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
const gridPower = realtime?.grid_power ?? 0;
|
const gridPower = Number(realtime?.grid_power) || 0;
|
||||||
const pvPower = realtime?.pv_power ?? 0;
|
const pvPower = Number(realtime?.pv_power) || 0;
|
||||||
const totalPower = realtime?.total_power ?? 0;
|
const totalPower = Number(realtime?.total_power) || Number(realtime?.total_load) || 0;
|
||||||
const hpPower = realtime?.heatpump_power ?? 0;
|
const hpPower = Number(realtime?.heatpump_power) || 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EnergyOverviewCard({ data, realtime }: Props) {
|
export default function EnergyOverviewCard({ data, realtime }: Props) {
|
||||||
const selfUseRate = data?.self_consumption_rate ?? 0;
|
const selfUseRate = Number(data?.self_consumption_rate) || 0;
|
||||||
|
|
||||||
const gaugeOption = {
|
const gaugeOption = {
|
||||||
series: [{
|
series: [{
|
||||||
@@ -46,14 +46,14 @@ export default function EnergyOverviewCard({ data, realtime }: Props) {
|
|||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statLabel}>今日用电</span>
|
<span className={styles.statLabel}>今日用电</span>
|
||||||
<span className={styles.statValueCyan}>
|
<span className={styles.statValueCyan}>
|
||||||
<AnimatedNumber value={data?.energy_today ?? 0} decimals={1} />
|
<AnimatedNumber value={Number(data?.energy_today) || 0} decimals={1} />
|
||||||
<span className={styles.unit}> kWh</span>
|
<span className={styles.unit}> kWh</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statLabel}>光伏发电</span>
|
<span className={styles.statLabel}>光伏发电</span>
|
||||||
<span className={styles.statValueGreen}>
|
<span className={styles.statValueGreen}>
|
||||||
<AnimatedNumber value={data?.pv_generation_today ?? 0} decimals={1} />
|
<AnimatedNumber value={Number(data?.pv_generation_today) || 0} decimals={1} />
|
||||||
<span className={styles.unit}> kWh</span>
|
<span className={styles.unit}> kWh</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@ export default function EnergyOverviewCard({ data, realtime }: Props) {
|
|||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statLabel}>电网购电</span>
|
<span className={styles.statLabel}>电网购电</span>
|
||||||
<span className={styles.statValueOrange}>
|
<span className={styles.statValueOrange}>
|
||||||
<AnimatedNumber value={data?.grid_import_today ?? 0} decimals={1} />
|
<AnimatedNumber value={Number(data?.grid_import_today) || 0} decimals={1} />
|
||||||
<span className={styles.unit}> kWh</span>
|
<span className={styles.unit}> kWh</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export default function LoadCurveCard({ loadData }: Props) {
|
|||||||
const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
||||||
const values = loadData?.values ?? new Array(24).fill(0);
|
const values = loadData?.values ?? new Array(24).fill(0);
|
||||||
const peak = values.length ? Math.max(...values) : 0;
|
const peak = values.length ? Math.max(...values) : 0;
|
||||||
const valley = values.length ? Math.min(...values.filter((v: number) => v > 0)) || 0 : 0;
|
const positiveValues = values.filter((v: number) => v > 0);
|
||||||
const avg = values.length ? values.reduce((a: number, b: number) => a + b, 0) / values.filter((v: number) => v > 0).length || 0 : 0;
|
const valley = positiveValues.length ? Math.min(...positiveValues) : 0;
|
||||||
|
const avg = positiveValues.length ? positiveValues.reduce((a: number, b: number) => a + b, 0) / positiveValues.length : 0;
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
grid: { left: 40, right: 12, top: 30, bottom: 24 },
|
grid: { left: 40, right: 12, top: 30, bottom: 24 },
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PVCard({ realtime, overview }: Props) {
|
export default function PVCard({ realtime, overview }: Props) {
|
||||||
const pvPower = realtime?.pv_power ?? 0;
|
const pvPower = Number(realtime?.pv_power) || 0;
|
||||||
const todayGen = overview?.pv_generation_today ?? 0;
|
const todayGen = Number(overview?.pv_generation_today) || 0;
|
||||||
const monthlyGen = overview?.pv_monthly_generation ?? 0;
|
const monthlyGen = Number(overview?.pv_monthly_generation) || 0;
|
||||||
const selfUseRate = overview?.self_consumption_rate ?? 0;
|
const selfUseRate = Number(overview?.self_consumption_rate) || 0;
|
||||||
|
|
||||||
// Donut for self-use ratio
|
// Donut for self-use ratio
|
||||||
const donutOption = {
|
const donutOption = {
|
||||||
|
|||||||
@@ -84,9 +84,50 @@ export default function BigScreen() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (get(0)) setOverview(get(0));
|
if (get(0)) {
|
||||||
if (get(1)) setRealtime(get(1));
|
const ov = get(0);
|
||||||
if (get(2)) setLoadData(get(2));
|
// Normalize overview: API returns nested energy_today.electricity structure
|
||||||
|
// but components expect flat fields like energy_today, pv_generation_today, etc.
|
||||||
|
const elec = ov?.energy_today?.electricity;
|
||||||
|
const normalized = {
|
||||||
|
...ov,
|
||||||
|
energy_today: elec?.consumption ?? ov?.energy_today ?? 0,
|
||||||
|
pv_generation_today: elec?.generation ?? ov?.pv_generation_today ?? 0,
|
||||||
|
// Flatten device_stats.total if present
|
||||||
|
device_total: ov?.device_stats?.total ?? 0,
|
||||||
|
self_consumption_rate: ov?.self_consumption_rate ?? 0,
|
||||||
|
};
|
||||||
|
setOverview(normalized);
|
||||||
|
// Also update deviceStats from overview if it has device_stats with total
|
||||||
|
if (ov?.device_stats) {
|
||||||
|
setDeviceStats((prev: any) => ({ ...prev, ...ov.device_stats }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (get(1)) {
|
||||||
|
const rt = get(1);
|
||||||
|
// Normalize: API returns total_load but components use total_power
|
||||||
|
setRealtime({
|
||||||
|
...rt,
|
||||||
|
total_power: rt?.total_power ?? rt?.total_load ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (get(2)) {
|
||||||
|
const raw = get(2);
|
||||||
|
// Normalize load curve: API returns [{time, power}] array
|
||||||
|
// but LoadCurveCard expects {hours: [...], values: [...]}
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const hours = raw.map((item: any) => {
|
||||||
|
const t = item.time || item.timestamp || '';
|
||||||
|
// Extract HH:00 from timestamp
|
||||||
|
const match = t.match(/(\d{2}:\d{2})/);
|
||||||
|
return match ? match[1] : t;
|
||||||
|
});
|
||||||
|
const values = raw.map((item: any) => item.power ?? item.value ?? 0);
|
||||||
|
setLoadData({ hours, values });
|
||||||
|
} else {
|
||||||
|
setLoadData(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (get(3)) {
|
if (get(3)) {
|
||||||
const alarms = get(3);
|
const alarms = get(3);
|
||||||
setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []);
|
setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []);
|
||||||
@@ -121,10 +162,11 @@ export default function BigScreen() {
|
|||||||
return d.toLocaleTimeString('zh-CN', { hour12: false });
|
return d.toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalDevices = deviceStats?.total ?? 0;
|
|
||||||
const onlineDevices = deviceStats?.online ?? 0;
|
const onlineDevices = deviceStats?.online ?? 0;
|
||||||
const offlineDevices = deviceStats?.offline ?? 0;
|
const offlineDevices = deviceStats?.offline ?? 0;
|
||||||
const alarmDevices = deviceStats?.alarm_count ?? alarmStats?.active_count ?? 0;
|
const alarmDevices = deviceStats?.alarm_count ?? deviceStats?.alarm ?? alarmStats?.active_count ?? 0;
|
||||||
|
const maintenanceDevices = deviceStats?.maintenance ?? 0;
|
||||||
|
const totalDevices = deviceStats?.total ?? (onlineDevices + offlineDevices + alarmDevices + maintenanceDevices) || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
|||||||
Reference in New Issue
Block a user