battery voltage monitor
This commit is contained in:
parent
67d5091093
commit
8c01912947
|
|
@ -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))]
|
||||||
|
|
|
||||||
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue