battery voltage monitor
This commit is contained in:
parent
67d5091093
commit
8c01912947
|
|
@ -3061,6 +3061,45 @@ public class Controller : ControllerBase
|
|||
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) ──────────────
|
||||
|
||||
[HttpGet(nameof(GetOnSiteChecklistForInstallation))]
|
||||
|
|
|
|||
|
|
@ -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; } = "";
|
||||
}
|
||||
|
|
@ -48,6 +48,11 @@ public static partial class Db
|
|||
return Insert(session);
|
||||
}
|
||||
|
||||
public static Boolean Create(BatteryVoltageAnomaly anomaly)
|
||||
{
|
||||
return Insert(anomaly);
|
||||
}
|
||||
|
||||
public static Boolean Create(InstallationAccess installationAccess)
|
||||
{
|
||||
return Insert(installationAccess);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ public static partial class Db
|
|||
public static TableQuery<OnSiteChecklistItem> OnSiteChecklistItems => Connection.Table<OnSiteChecklistItem>();
|
||||
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()
|
||||
{
|
||||
|
|
@ -98,6 +101,9 @@ public static partial class Db
|
|||
// On-site installer checklist (sodistore home)
|
||||
Connection.CreateTable<OnSiteChecklistItem>();
|
||||
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
|
||||
|
|
@ -281,6 +287,9 @@ public static partial class Db
|
|||
fileConnection.CreateTable<OnSiteChecklistItem>();
|
||||
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
|
||||
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
|
||||
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
|
||||
|
|
|
|||
|
|
@ -375,4 +375,9 @@ public static partial class Db
|
|||
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}).");
|
||||
}
|
||||
|
||||
public static void DeleteBatteryVoltageAnomaliesBefore(String isoDate)
|
||||
{
|
||||
Connection.Execute("DELETE FROM BatteryVoltageAnomaly WHERE Date < ?", isoDate);
|
||||
}
|
||||
}
|
||||
|
|
@ -272,4 +272,19 @@ public static partial class Db
|
|||
|
||||
public static OnSiteChecklistSignature? GetOnSiteChecklistSignature(Int64 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();
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ public static class Program
|
|||
AlarmReviewService.StartDailyScheduler();
|
||||
DailyIngestionService.StartScheduler();
|
||||
ReportAggregationService.StartScheduler();
|
||||
BatteryVoltageCheckService.StartScheduler();
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
RabbitMqManager.InitializeEnvironment();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 08–17 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Paper,
|
||||
|
|
@ -24,6 +25,10 @@ import {
|
|||
ActiveCluster,
|
||||
getActiveClusters
|
||||
} 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 {
|
||||
values: JSONRecordData;
|
||||
|
|
@ -47,6 +52,22 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
|
||||
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 parsed = getActiveClusters(batterySerialNumbers || '');
|
||||
if (parsed.length > 0) return parsed;
|
||||
|
|
@ -376,6 +397,67 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
</TableContainer>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||
import routes from '../../../Resources/routes.json';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import { getDeviceTypeName } from '../Information/installationSetupUtils';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import { UserType } from 'src/interfaces/UserTypes';
|
||||
|
|
@ -27,6 +28,10 @@ import {
|
|||
CHECKLIST_ENABLED_PRODUCTS,
|
||||
ChecklistSummary
|
||||
} from 'src/interfaces/ChecklistTypes';
|
||||
import {
|
||||
BatteryAnomalySummary,
|
||||
VOLTAGE_ANOMALY_PRODUCTS
|
||||
} from 'src/interfaces/BatteryAnomalyTypes';
|
||||
import SetupProgress from '../Checklist/SetupProgress';
|
||||
|
||||
interface FlatInstallationViewProps {
|
||||
|
|
@ -44,12 +49,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
const showChecklistColumn =
|
||||
currentUser?.userType === UserType.admin &&
|
||||
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
|
||||
const showAnomalyColumn =
|
||||
currentUser?.userType === UserType.admin &&
|
||||
VOLTAGE_ANOMALY_PRODUCTS.has(props.product ?? -1);
|
||||
const isListViewPath =
|
||||
currentLocation.pathname === baseRoute + 'list' ||
|
||||
currentLocation.pathname === baseRoute + routes.list;
|
||||
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
|
||||
new Map()
|
||||
);
|
||||
const [anomalyMap, setAnomalyMap] = useState<Map<number, BatteryAnomalySummary>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showChecklistColumn || !isListViewPath) return;
|
||||
|
|
@ -66,6 +77,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
.catch(() => setProgressMap(new Map()));
|
||||
}, [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(() => {
|
||||
return [...props.installations].sort((a, b) => {
|
||||
// Data-collection-disabled installations sink below everything (even offline).
|
||||
|
|
@ -158,6 +184,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showAnomalyColumn && (
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="batteryVoltageAnomaly"
|
||||
defaultMessage="Full @ low voltage"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -335,6 +369,49 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
})()}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
@ -801,6 +801,10 @@
|
|||
"checklistStep10Sub3": "Atef kontaktieren, falls externes EMS vorhanden ist",
|
||||
"checklistNoAttachments": "Noch keine Datei angehängt.",
|
||||
"setupProgress": "Monitor-Onboarding-Fortschritt",
|
||||
"batteryVoltageAnomaly": "Voll bei Unterspannung",
|
||||
"voltageAnomalyLogTitle": "Protokoll: voll bei Unterspannung",
|
||||
"voltageAnomalyTime": "Zeit",
|
||||
"noVoltageAnomalies": "Keine Anomalien erfasst",
|
||||
"checklistPhaseEmpty": "Nicht gestartet",
|
||||
"checklistPhasePreparation": "Vorbereitung",
|
||||
"checklistPhaseOnSite": "Vor Ort",
|
||||
|
|
|
|||
|
|
@ -549,6 +549,10 @@
|
|||
"checklistStep10Sub3": "Contact Atef if there is external EMS",
|
||||
"checklistNoAttachments": "No file attached yet.",
|
||||
"setupProgress": "Monitor Onboarding Progress",
|
||||
"batteryVoltageAnomaly": "Full @ low voltage",
|
||||
"voltageAnomalyLogTitle": "Full-charge low-voltage log",
|
||||
"voltageAnomalyTime": "Time",
|
||||
"noVoltageAnomalies": "No anomalies recorded",
|
||||
"checklistPhaseEmpty": "Not started",
|
||||
"checklistPhasePreparation": "Preparation",
|
||||
"checklistPhaseOnSite": "On-site",
|
||||
|
|
|
|||
|
|
@ -801,6 +801,10 @@
|
|||
"checklistStep10Sub3": "Contacter Atef en cas d'EMS externe",
|
||||
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
|
||||
"setupProgress": "Progression d'onboarding Monitor",
|
||||
"batteryVoltageAnomaly": "Plein à basse tension",
|
||||
"voltageAnomalyLogTitle": "Journal : plein à basse tension",
|
||||
"voltageAnomalyTime": "Heure",
|
||||
"noVoltageAnomalies": "Aucune anomalie enregistrée",
|
||||
"checklistPhaseEmpty": "Non commencé",
|
||||
"checklistPhasePreparation": "Préparation",
|
||||
"checklistPhaseOnSite": "Sur site",
|
||||
|
|
|
|||
|
|
@ -801,6 +801,10 @@
|
|||
"checklistStep10Sub3": "Contattare Atef se è presente un EMS esterno",
|
||||
"checklistNoAttachments": "Nessun file allegato.",
|
||||
"setupProgress": "Avanzamento onboarding Monitor",
|
||||
"batteryVoltageAnomaly": "Pieno a bassa tensione",
|
||||
"voltageAnomalyLogTitle": "Registro: pieno a bassa tensione",
|
||||
"voltageAnomalyTime": "Ora",
|
||||
"noVoltageAnomalies": "Nessuna anomalia registrata",
|
||||
"checklistPhaseEmpty": "Non avviato",
|
||||
"checklistPhasePreparation": "Preparazione",
|
||||
"checklistPhaseOnSite": "In sito",
|
||||
|
|
|
|||
Loading…
Reference in New Issue