battery voltage monitor

This commit is contained in:
Yinyin Liu 2026-06-02 15:18:37 +02:00
parent 67d5091093
commit 8c01912947
16 changed files with 559 additions and 2 deletions

View File

@ -3061,6 +3061,45 @@ public class Controller : ControllerBase
return Ok(summaries); return Ok(summaries);
} }
// ── Battery full-charge low-voltage anomaly (temporary diagnostic) ──
[HttpGet(nameof(GetBatteryVoltageAnomalySummary))]
public ActionResult<IEnumerable<Object>> GetBatteryVoltageAnomalySummary(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// DetectedAt is "yyyy-MM-dd HH:mm:ss" — fixed-width, so lexicographic == chronological.
// If the format ever changes, this ordering breaks silently.
var summary = Db.BatteryVoltageAnomalies
.ToList()
.GroupBy(a => a.InstallationId)
.Select(g =>
{
var latest = g.OrderByDescending(a => a.DetectedAt).First();
return new
{
installationId = g.Key,
lastDetectedAt = latest.DetectedAt,
lastVoltage = latest.Voltage,
lastBattery = latest.BatteryLabel,
count = g.Count()
};
})
.ToList();
return Ok(summary);
}
[HttpGet(nameof(GetBatteryVoltageAnomalyLog))]
public ActionResult<IEnumerable<BatteryVoltageAnomaly>> GetBatteryVoltageAnomalyLog(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(Db.GetBatteryVoltageAnomalies(installationId));
}
// ── On-Site Installer Checklist (sodistore home only) ────────────── // ── On-Site Installer Checklist (sodistore home only) ──────────────
[HttpGet(nameof(GetOnSiteChecklistForInstallation))] [HttpGet(nameof(GetOnSiteChecklistForInstallation))]

View File

@ -0,0 +1,30 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
// Temporary diagnostic: one row per battery cluster per hour where SOC=100% & pack voltage < 58V
// ("BMS fake-full"). When this feature is no longer needed, drop the table, this type,
// BatteryVoltageCheckService, BatteryVoltageAnomalyDetector, the two endpoints, the Db.cs
// CreateTable entries (main + fileConnection), and the related Create/Read/Delete helpers.
public class BatteryVoltageAnomaly
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
[Indexed]
public Int64 InstallationId { get; set; }
public String BatteryLabel { get; set; } = "";
public String DetectedAt { get; set; } = "";
[Indexed]
public String Date { get; set; } = "";
public String DateHour { get; set; } = "";
public Double Voltage { get; set; }
public Double Soc { get; set; }
public String CreatedAt { get; set; } = "";
}

View File

@ -47,7 +47,12 @@ public static partial class Db
{ {
return Insert(session); return Insert(session);
} }
public static Boolean Create(BatteryVoltageAnomaly anomaly)
{
return Insert(anomaly);
}
public static Boolean Create(InstallationAccess installationAccess) public static Boolean Create(InstallationAccess installationAccess)
{ {
return Insert(installationAccess); return Insert(installationAccess);

View File

@ -50,6 +50,9 @@ public static partial class Db
public static TableQuery<OnSiteChecklistItem> OnSiteChecklistItems => Connection.Table<OnSiteChecklistItem>(); public static TableQuery<OnSiteChecklistItem> OnSiteChecklistItems => Connection.Table<OnSiteChecklistItem>();
public static TableQuery<OnSiteChecklistSignature> OnSiteChecklistSignatures => Connection.Table<OnSiteChecklistSignature>(); public static TableQuery<OnSiteChecklistSignature> OnSiteChecklistSignatures => Connection.Table<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
public static TableQuery<BatteryVoltageAnomaly> BatteryVoltageAnomalies => Connection.Table<BatteryVoltageAnomaly>();
public static void Init() public static void Init()
{ {
@ -98,6 +101,9 @@ public static partial class Db
// On-site installer checklist (sodistore home) // On-site installer checklist (sodistore home)
Connection.CreateTable<OnSiteChecklistItem>(); Connection.CreateTable<OnSiteChecklistItem>();
Connection.CreateTable<OnSiteChecklistSignature>(); Connection.CreateTable<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
Connection.CreateTable<BatteryVoltageAnomaly>();
}); });
// One-time migration: normalize legacy long-form language values to ISO codes // One-time migration: normalize legacy long-form language values to ISO codes
@ -281,6 +287,9 @@ public static partial class Db
fileConnection.CreateTable<OnSiteChecklistItem>(); fileConnection.CreateTable<OnSiteChecklistItem>();
fileConnection.CreateTable<OnSiteChecklistSignature>(); fileConnection.CreateTable<OnSiteChecklistSignature>();
// Battery full-charge low-voltage anomaly log (temporary diagnostic)
fileConnection.CreateTable<BatteryVoltageAnomaly>();
// Migrate new columns: set defaults for existing rows where NULL or empty // Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");

View File

@ -375,4 +375,9 @@ public static partial class Db
Backup(); Backup();
Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff})."); Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
} }
public static void DeleteBatteryVoltageAnomaliesBefore(String isoDate)
{
Connection.Execute("DELETE FROM BatteryVoltageAnomaly WHERE Date < ?", isoDate);
}
} }

View File

@ -272,4 +272,19 @@ public static partial class Db
public static OnSiteChecklistSignature? GetOnSiteChecklistSignature(Int64 installationId) public static OnSiteChecklistSignature? GetOnSiteChecklistSignature(Int64 installationId)
=> OnSiteChecklistSignatures.FirstOrDefault(s => s.InstallationId == installationId); => OnSiteChecklistSignatures.FirstOrDefault(s => s.InstallationId == installationId);
// ── BatteryVoltageAnomaly Queries (temporary diagnostic) ────────────
public static Boolean BatteryVoltageAnomalyExists(Int64 installationId, String batteryLabel, String dateHour)
=> BatteryVoltageAnomalies.Any(a =>
a.InstallationId == installationId &&
a.BatteryLabel == batteryLabel &&
a.DateHour == dateHour);
public static List<BatteryVoltageAnomaly> GetBatteryVoltageAnomalies(Int64 installationId)
=> BatteryVoltageAnomalies
.Where(a => a.InstallationId == installationId)
.ToList()
.OrderByDescending(a => a.DetectedAt)
.ToList();
} }

View File

@ -36,6 +36,7 @@ public static class Program
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
DailyIngestionService.StartScheduler(); DailyIngestionService.StartScheduler();
ReportAggregationService.StartScheduler(); ReportAggregationService.StartScheduler();
BatteryVoltageCheckService.StartScheduler();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment(); RabbitMqManager.InitializeEnvironment();

View File

@ -0,0 +1,113 @@
using System.Text;
using System.Text.Json;
namespace InnovEnergy.App.Backend.Services;
public readonly record struct VoltageAnomaly(String BatteryLabel, Double Soc, Double Voltage, DateTime? Timestamp);
public static class BatteryVoltageAnomalyDetector
{
private const Double FullSoc = 100.0;
private const Double LowVoltageVolt = 58.0;
private const Int32 MaxClusters = 8;
public static List<VoltageAnomaly> Detect(String liveJson, Int32 device)
{
var decoded = Decode(liveJson);
using var doc = JsonDocument.Parse(decoded);
var root = Unwrap(doc.RootElement);
var ts = ReadTimestamp(root);
return device switch
{
3 => DetectGrowatt(root, ts),
4 => DetectSinexcel(root, ts),
_ => new List<VoltageAnomaly>(),
};
}
private static List<VoltageAnomaly> DetectSinexcel(JsonElement root, DateTime? ts)
{
var found = new List<VoltageAnomaly>();
if (!root.TryGetProperty("Devices", out var devices) || devices.ValueKind != JsonValueKind.Object)
{
Console.WriteLine("[BattVoltage] Sinexcel JSON has no Devices node — check live-file shape assumption.");
return found;
}
foreach (var dev in devices.EnumerateObject())
{
for (var cl = 1; cl <= MaxClusters; cl++)
{
// Battery1 uses "SocSecondvalue" (capital S), Battery2 uses "Socsecondvalue"
// (lowercase s) per the documented live-data schema; try both casings.
var soc = ReadDouble(dev.Value, $"Battery{cl}Soc")
?? ReadDouble(dev.Value, $"Battery{cl}SocSecondvalue")
?? ReadDouble(dev.Value, $"Battery{cl}Socsecondvalue");
var v = ReadDouble(dev.Value, $"Battery{cl}PackTotalVoltage");
if (soc is null || v is null) continue;
if (IsAnomaly(soc.Value, v.Value))
found.Add(new VoltageAnomaly($"Inv{dev.Name}/Bat{cl}", soc.Value, v.Value, ts));
}
}
return found;
}
private static List<VoltageAnomaly> DetectGrowatt(JsonElement root, DateTime? ts)
{
var found = new List<VoltageAnomaly>();
for (var i = 1; i <= MaxClusters; i++)
{
var soc = ReadDouble(root, $"Battery{i}Soc");
var v = ReadDouble(root, $"Battery{i}Voltage");
if (soc is null || v is null) continue;
if (IsAnomaly(soc.Value, v.Value))
found.Add(new VoltageAnomaly($"Bat{i}", soc.Value, v.Value, ts));
}
return found;
}
private static Boolean IsAnomaly(Double soc, Double v) => soc >= FullSoc && v < LowVoltageVolt;
private static Double? ReadDouble(JsonElement e, String name)
{
if (!e.TryGetProperty(name, out var p)) return null;
if (p.ValueKind == JsonValueKind.Number && p.TryGetDouble(out var d)) return d;
if (p.ValueKind == JsonValueKind.String &&
Double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var ds)) return ds;
return null;
}
private static DateTime? ReadTimestamp(JsonElement e)
{
if (!e.TryGetProperty("Timestamp", out var p)) return null;
if (p.ValueKind == JsonValueKind.String && p.TryGetDateTime(out var dt))
{
// Unspecified-Kind (no offset in source) is assumed UTC; otherwise convert.
// Without this, downstream ToString("yyyy-MM-dd HH") prints local-time for
// some sources and UTC for others, breaking the (..., DateHour) dedup key
// and the cleanup-cutoff comparison.
return dt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: dt.ToUniversalTime();
}
if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var unix))
return DateTimeOffset.FromUnixTimeSeconds(unix).UtcDateTime;
return null;
}
private static JsonElement Unwrap(JsonElement root)
=> root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("InverterRecord", out var inner)
? inner
: root;
private static String Decode(String raw)
{
var t = raw.Trim();
if (t.StartsWith('{') || t.StartsWith('[')) return raw;
try { return Encoding.UTF8.GetString(Convert.FromBase64String(t)); }
catch { return raw; }
}
}

View File

@ -0,0 +1,144 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
namespace InnovEnergy.App.Backend.Services;
public static class BatteryVoltageCheckService
{
private static Timer? _timer;
private const Int32 WindowStartUtc = 8;
private const Int32 WindowEndUtc = 17;
private const Int32 RetentionDays = 90;
private const Int64 MinUnixTs = 1_000_000_000L;
public static void StartScheduler()
{
_timer = new Timer(_ =>
{
try { RunOnce().GetAwaiter().GetResult(); }
catch (Exception ex) { Console.Error.WriteLine($"[BattVoltage] Scheduler error: {ex.Message}"); }
}, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30));
Console.WriteLine($"[BattVoltage] Scheduler started (every 30 min, UTC {WindowStartUtc:00}:00{WindowEndUtc:00}:00).");
}
private static async Task RunOnce()
{
// Retention runs regardless of the midday window — otherwise a backend whose
// uptime never overlaps 0817 UTC (frequent evening restarts) never prunes.
CleanupOld();
var hour = DateTime.UtcNow.Hour;
if (hour < WindowStartUtc || hour >= WindowEndUtc) return;
var installations = Db.Installations
.Where(i => (i.Product == (Int32)ProductType.SodioHome ||
i.Product == (Int32)ProductType.SodistorePro)
&& i.DataCollectionEnabled)
.ToList();
var total = 0;
foreach (var inst in installations)
{
try { total += await CheckInstallation(inst); }
catch (Exception ex) { Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} failed: {ex.Message}"); }
}
if (total > 0) Console.WriteLine($"[BattVoltage] Run complete: {total} new anomaly row(s).");
}
private static async Task<Int32> CheckInstallation(Installation inst)
{
var liveJson = await TryReadNewestLiveFile(inst);
if (liveJson is null) return 0;
List<VoltageAnomaly> anomalies;
try { anomalies = BatteryVoltageAnomalyDetector.Detect(liveJson, inst.Device); }
catch (Exception ex)
{
Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} detect failed: {ex.Message}");
return 0;
}
var inserted = 0;
foreach (var a in anomalies)
{
var detected = a.Timestamp ?? DateTime.UtcNow;
var dateHour = detected.ToString("yyyy-MM-dd HH");
if (Db.BatteryVoltageAnomalyExists(inst.Id, a.BatteryLabel, dateHour)) continue;
Db.Create(new BatteryVoltageAnomaly
{
InstallationId = inst.Id,
BatteryLabel = a.BatteryLabel,
DetectedAt = detected.ToString("yyyy-MM-dd HH:mm:ss"),
Date = detected.ToString("yyyy-MM-dd"),
DateHour = dateHour,
Voltage = Math.Round(a.Voltage, 2),
Soc = a.Soc,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
inserted++;
Console.WriteLine($"[BattVoltage] Inst {inst.Id} {a.BatteryLabel}: SOC {a.Soc} @ {a.Voltage:F2}V");
}
return inserted;
}
private static async Task<String?> TryReadNewestLiveFile(Installation inst)
{
try
{
var region = new S3Region($"https://{inst.S3Region}.{inst.S3Provider}", ExoCmd.S3Credentials!);
var bucket = region.Bucket(inst.BucketName());
// Prefix-scope to recent files: first 5 digits of current unix-ts covers
// a ~27h window (vs a full-bucket scan of potentially 100k+ objects on
// 10-second cadence). At a digit boundary we may briefly miss until next
// poll — acceptable for a 30-min temporary diagnostic.
var nowTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var prefix = (nowTs / 100_000L).ToString();
S3Url? newest = null;
var newestTs = Int64.MinValue;
await foreach (var url in bucket.ListObjects(prefix))
{
if (!TryParseUnixTs(url.Path, out var ts)) continue;
if (ts <= newestTs) continue;
newestTs = ts;
newest = url;
}
if (newest is null) return null;
return await newest.GetObjectAsString();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[BattVoltage] Inst {inst.Id} S3 read failed: {ex.Message}");
return null;
}
}
private static Boolean TryParseUnixTs(String path, out Int64 ts)
{
ts = 0;
var slash = path.LastIndexOf('/');
var name = slash >= 0 ? path[(slash + 1)..] : path;
var dot = name.LastIndexOf('.');
var stem = dot > 0 ? name[..dot] : name;
if (!Int64.TryParse(stem, out var v)) return false;
if (v < MinUnixTs) return false;
ts = v;
return true;
}
private static void CleanupOld()
{
var cutoff = DateTime.UtcNow.AddDays(-RetentionDays).ToString("yyyy-MM-dd");
Db.DeleteBatteryVoltageAnomaliesBefore(cutoff);
}
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useContext, useEffect, useMemo, useState } from 'react';
import { import {
Card,
Container, Container,
Grid, Grid,
Paper, Paper,
@ -24,6 +25,10 @@ import {
ActiveCluster, ActiveCluster,
getActiveClusters getActiveClusters
} from '../Information/installationSetupUtils'; } from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import { BatteryAnomalyLogEntry } from 'src/interfaces/BatteryAnomalyTypes';
interface BatteryViewSodioHomeProps { interface BatteryViewSodioHomeProps {
values: JSONRecordData; values: JSONRecordData;
@ -47,6 +52,22 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
const hasDevices = !!inverter?.Devices; const hasDevices = !!inverter?.Devices;
const { currentUser } = useContext(UserContext);
const showAnomalyLog = currentUser?.userType === UserType.admin;
const [anomalyLog, setAnomalyLog] = useState<BatteryAnomalyLogEntry[]>([]);
useEffect(() => {
if (!showAnomalyLog || !props.installationId) return;
axiosConfig
.get('/GetBatteryVoltageAnomalyLog', {
params: { installationId: props.installationId }
})
.then((res) => {
if (Array.isArray(res.data)) setAnomalyLog(res.data);
})
.catch(() => setAnomalyLog([]));
}, [showAnomalyLog, props.installationId]);
const activeClusters: ActiveCluster[] = useMemo(() => { const activeClusters: ActiveCluster[] = useMemo(() => {
const parsed = getActiveClusters(batterySerialNumbers || ''); const parsed = getActiveClusters(batterySerialNumbers || '');
if (parsed.length > 0) return parsed; if (parsed.length > 0) return parsed;
@ -376,6 +397,67 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
</TableContainer> </TableContainer>
</Container> </Container>
)} )}
{showAnomalyLog && (
<Container maxWidth="lg" sx={{ marginTop: '20px' }}>
<Card>
<Typography
variant="h6"
sx={{ padding: '12px 16px', fontWeight: 'bold' }}
>
<FormattedMessage
id="voltageAnomalyLogTitle"
defaultMessage="Full-charge low-voltage log"
/>
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>
<FormattedMessage id="battery" defaultMessage="Battery" />
</TableCell>
<TableCell>
<FormattedMessage
id="voltageAnomalyTime"
defaultMessage="Time"
/>
</TableCell>
<TableCell>
<FormattedMessage id="voltage" defaultMessage="Voltage" />
</TableCell>
<TableCell>
<FormattedMessage id="soc" defaultMessage="SOC" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{anomalyLog.length === 0 ? (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noVoltageAnomalies"
defaultMessage="No anomalies recorded"
/>
</Typography>
</TableCell>
</TableRow>
) : (
anomalyLog.map((entry) => (
<TableRow key={entry.id}>
<TableCell>{entry.batteryLabel}</TableCell>
<TableCell>{entry.detectedAt}</TableCell>
<TableCell>{entry.voltage.toFixed(2)} V</TableCell>
<TableCell>{entry.soc.toFixed(0)} %</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Card>
</Container>
)}
</> </>
); );
} }

View File

@ -19,6 +19,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { getDeviceTypeName } from '../Information/installationSetupUtils'; import { getDeviceTypeName } from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext'; import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes'; import { UserType } from 'src/interfaces/UserTypes';
@ -27,6 +28,10 @@ import {
CHECKLIST_ENABLED_PRODUCTS, CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary ChecklistSummary
} from 'src/interfaces/ChecklistTypes'; } from 'src/interfaces/ChecklistTypes';
import {
BatteryAnomalySummary,
VOLTAGE_ANOMALY_PRODUCTS
} from 'src/interfaces/BatteryAnomalyTypes';
import SetupProgress from '../Checklist/SetupProgress'; import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps { interface FlatInstallationViewProps {
@ -44,12 +49,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const showChecklistColumn = const showChecklistColumn =
currentUser?.userType === UserType.admin && currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1); CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
const showAnomalyColumn =
currentUser?.userType === UserType.admin &&
VOLTAGE_ANOMALY_PRODUCTS.has(props.product ?? -1);
const isListViewPath = const isListViewPath =
currentLocation.pathname === baseRoute + 'list' || currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list; currentLocation.pathname === baseRoute + routes.list;
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>( const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map() new Map()
); );
const [anomalyMap, setAnomalyMap] = useState<Map<number, BatteryAnomalySummary>>(
new Map()
);
useEffect(() => { useEffect(() => {
if (!showChecklistColumn || !isListViewPath) return; if (!showChecklistColumn || !isListViewPath) return;
@ -66,6 +77,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
.catch(() => setProgressMap(new Map())); .catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, isListViewPath]); }, [showChecklistColumn, isListViewPath]);
useEffect(() => {
if (!showAnomalyColumn || !isListViewPath) return;
axiosConfig
.get('/GetBatteryVoltageAnomalySummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, BatteryAnomalySummary>();
res.data.forEach((s: BatteryAnomalySummary) => {
map.set(s.installationId, s);
});
setAnomalyMap(map);
})
.catch(() => setAnomalyMap(new Map()));
}, [showAnomalyColumn, isListViewPath]);
const sortedInstallations = useMemo(() => { const sortedInstallations = useMemo(() => {
return [...props.installations].sort((a, b) => { return [...props.installations].sort((a, b) => {
// Data-collection-disabled installations sink below everything (even offline). // Data-collection-disabled installations sink below everything (even offline).
@ -158,6 +184,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
/> />
</TableCell> </TableCell>
)} )}
{showAnomalyColumn && (
<TableCell>
<FormattedMessage
id="batteryVoltageAnomaly"
defaultMessage="Full @ low voltage"
/>
</TableCell>
)}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -335,6 +369,49 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
})()} })()}
</TableCell> </TableCell>
)} )}
{showAnomalyColumn && (
<TableCell>
{(() => {
const a = anomalyMap.get(installation.id);
if (!a) {
return (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: 'small' }}
>
</Typography>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<WarningAmberIcon
style={{
width: '20px',
height: '20px',
color: '#f7b34d'
}}
/>
<Typography
variant="body2"
color="text.primary"
noWrap
sx={{ fontSize: 'small' }}
>
{a.lastDetectedAt} · {a.lastVoltage.toFixed(2)} V
</Typography>
</div>
);
})()}
</TableCell>
)}
</HoverableTableRow> </HoverableTableRow>
); );
})} })}

View File

@ -0,0 +1,21 @@
export interface BatteryAnomalySummary {
installationId: number;
lastDetectedAt: string;
lastVoltage: number;
lastBattery: string;
count: number;
}
export interface BatteryAnomalyLogEntry {
id: number;
installationId: number;
batteryLabel: string;
detectedAt: string;
date: string;
dateHour: string;
voltage: number;
soc: number;
createdAt: string;
}
export const VOLTAGE_ANOMALY_PRODUCTS = new Set<number>([2, 5]);

View File

@ -801,6 +801,10 @@
"checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist", "checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist",
"checklistNoAttachments": "Noch keine Datei angehängt.", "checklistNoAttachments": "Noch keine Datei angehängt.",
"setupProgress": "Monitor-Onboarding-Fortschritt", "setupProgress": "Monitor-Onboarding-Fortschritt",
"batteryVoltageAnomaly": "Voll bei Unterspannung",
"voltageAnomalyLogTitle": "Protokoll: voll bei Unterspannung",
"voltageAnomalyTime": "Zeit",
"noVoltageAnomalies": "Keine Anomalien erfasst",
"checklistPhaseEmpty": "Nicht gestartet", "checklistPhaseEmpty": "Nicht gestartet",
"checklistPhasePreparation": "Vorbereitung", "checklistPhasePreparation": "Vorbereitung",
"checklistPhaseOnSite": "Vor Ort", "checklistPhaseOnSite": "Vor Ort",

View File

@ -549,6 +549,10 @@
"checklistStep10Sub3": "Contact Atef if there is external EMS", "checklistStep10Sub3": "Contact Atef if there is external EMS",
"checklistNoAttachments": "No file attached yet.", "checklistNoAttachments": "No file attached yet.",
"setupProgress": "Monitor Onboarding Progress", "setupProgress": "Monitor Onboarding Progress",
"batteryVoltageAnomaly": "Full @ low voltage",
"voltageAnomalyLogTitle": "Full-charge low-voltage log",
"voltageAnomalyTime": "Time",
"noVoltageAnomalies": "No anomalies recorded",
"checklistPhaseEmpty": "Not started", "checklistPhaseEmpty": "Not started",
"checklistPhasePreparation": "Preparation", "checklistPhasePreparation": "Preparation",
"checklistPhaseOnSite": "On-site", "checklistPhaseOnSite": "On-site",

View File

@ -801,6 +801,10 @@
"checklistStep10Sub3": "Contacter Atef en cas d'EMS externe", "checklistStep10Sub3": "Contacter Atef en cas d'EMS externe",
"checklistNoAttachments": "Aucun fichier joint pour le moment.", "checklistNoAttachments": "Aucun fichier joint pour le moment.",
"setupProgress": "Progression d'onboarding Monitor", "setupProgress": "Progression d'onboarding Monitor",
"batteryVoltageAnomaly": "Plein à basse tension",
"voltageAnomalyLogTitle": "Journal : plein à basse tension",
"voltageAnomalyTime": "Heure",
"noVoltageAnomalies": "Aucune anomalie enregistrée",
"checklistPhaseEmpty": "Non commencé", "checklistPhaseEmpty": "Non commencé",
"checklistPhasePreparation": "Préparation", "checklistPhasePreparation": "Préparation",
"checklistPhaseOnSite": "Sur site", "checklistPhaseOnSite": "Sur site",

View File

@ -801,6 +801,10 @@
"checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno", "checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno",
"checklistNoAttachments": "Nessun file allegato.", "checklistNoAttachments": "Nessun file allegato.",
"setupProgress": "Avanzamento onboarding Monitor", "setupProgress": "Avanzamento onboarding Monitor",
"batteryVoltageAnomaly": "Pieno a bassa tensione",
"voltageAnomalyLogTitle": "Registro: pieno a bassa tensione",
"voltageAnomalyTime": "Ora",
"noVoltageAnomalies": "Nessuna anomalia registrata",
"checklistPhaseEmpty": "Non avviato", "checklistPhaseEmpty": "Non avviato",
"checklistPhasePreparation": "Preparazione", "checklistPhasePreparation": "Preparazione",
"checklistPhaseOnSite": "In sito", "checklistPhaseOnSite": "In sito",