diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 4c17a392a..63a0fdb1e 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -11,6 +11,9 @@ public class Configuration public double? MaximumDischargingCurrent { get; set; } public double? MaximumChargingCurrent { get; set; } + // Nested per-inverter / per-cluster topology + limits (Sinexcel). + // Keys: "Inverter1".."InverterN" → { Clusters: { "Cluster1".. }, PvCount } + public Dictionary? Inverters { get; set; } public double? OperatingPriority { get; set; } public int? InverterNumber { get; set; } public double? BatteriesCount { get; set; } @@ -77,4 +80,17 @@ public enum CalibrationChargeType RepetitivelyEvery, AdditionallyOnce, ChargePermanently +} + +public class InverterConfig +{ + public Dictionary Clusters { get; set; } = new(); + public int PvCount { get; set; } +} + +public class ClusterConfig +{ + public int BatteryCount { get; set; } + public double MaxChargingCurrent { get; set; } + public double MaxDischargingCurrent { get; set; } } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 677a9ee71..f4a0a49fc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -685,6 +685,17 @@ export interface I_BoxDataValue { value: string | number; } +export type ClusterConfig = { + BatteryCount: number; + MaxChargingCurrent: number; + MaxDischargingCurrent: number; +}; + +export type InverterConfig = { + Clusters: { [clusterKey: string]: ClusterConfig }; + PvCount: number; +}; + export type ConfigurationValues = { minimumSoC: string | number; gridSetPoint: number; @@ -696,6 +707,9 @@ export type ConfigurationValues = { //For sodistoreHome maximumDischargingCurrent: number; maximumChargingCurrent: number; + // Nested per-inverter / per-cluster topology + limits (Sinexcel). + // Keys: "Inverter1".."InverterN" → { Clusters: { "Cluster1".. }, PvCount } + inverters?: { [inverterKey: string]: InverterConfig }; operatingPriority: number; batteriesCount: number; inverterNumber: number; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 463c09ba5..accd5fd16 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -1,8 +1,12 @@ -import { ConfigurationValues, JSONRecordData } from '../Log/graph.util'; +import { ConfigurationValues, InverterConfig, JSONRecordData } from '../Log/graph.util'; import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, Box, CardContent, + Chip, CircularProgress, Container, Divider, @@ -17,6 +21,7 @@ import { Typography, useTheme } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import React, { useContext, useState, useEffect } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -30,7 +35,9 @@ import { I_Installation } from 'src/interfaces/InstallationTypes'; import { buildSodistoreProPreset, getPresetsForDevice, - PresetConfig + parseBatterySnTree, + PresetConfig, + BatterySnTree } from '../Information/installationSetupUtils'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -104,15 +111,67 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { // Storage key for pending config (optimistic update) const pendingConfigKey = `pendingConfig_${props.id}`; + // Hardware topology — derived from Information tab (single source of truth). + const isSodistorePro = product === 5; + const installationModel = props.installation.installationModel; + const presetConfig: PresetConfig | null = isSodistorePro + ? (installationModel && parseInt(installationModel, 10) > 0 + ? buildSodistoreProPreset(parseInt(installationModel, 10)) + : null) + : (getPresetsForDevice(device)[installationModel] || null); + const inverterCount = presetConfig?.length ?? 1; + + // Build the nested Inverters config from topology (presetConfig). + // Used as fallback when the device hasn't yet written the structured Inverters object. + const buildInvertersFromPreset = ( + chargeScalar: number | undefined, + dischargeScalar: number | undefined, + ): { [k: string]: InverterConfig } => { + if (!presetConfig) return {}; + const out: { [k: string]: InverterConfig } = {}; + presetConfig.forEach((clusters, invIdx) => { + const clObj: { [k: string]: any } = {}; + clusters.forEach((batteryCount, clIdx) => { + clObj[`Cluster${clIdx + 1}`] = { + BatteryCount: batteryCount, + MaxChargingCurrent: chargeScalar ?? 0, + MaxDischargingCurrent: dischargeScalar ?? 0, + }; + }); + out[`Inverter${invIdx + 1}`] = { Clusters: clObj, PvCount: 0 }; + }); + return out; + }; + // Helper to build form values from S3 data const getS3Values = (): Partial => { const inverterNum = props.values.Config.InverterNumber ?? 1; const batteriesPerInverter: number[] = props.values.Config.BatteriesCountPerInverter ?? Array(inverterNum).fill(props.values.Config.BatteriesCount || 1); + // Read per-inverter Clusters/PvCount from each Devices.InverterN entry on disk. + const cfgDevices = (props.values.Config as any).Devices as { [k: string]: any } | undefined; + const cfgInverters: { [k: string]: InverterConfig } | undefined = cfgDevices + ? Object.fromEntries( + Object.entries(cfgDevices) + .filter(([k, v]: [string, any]) => k.startsWith('Inverter') && (v?.Clusters || v?.PvCount != null)) + .map(([k, v]: [string, any]) => [k, { Clusters: v.Clusters ?? {}, PvCount: v.PvCount ?? 0 }]) + ) + : undefined; + const hasInverterData = cfgInverters && Object.keys(cfgInverters).length > 0; return { minimumSoC: props.values.Config.MinSoc, maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent, maximumChargingCurrent: props.values.Config.MaximumChargingCurrent, + // Always overlay Information-tab topology so battery counts and PV count + // reflect what's actually installed (Information tab is source of truth). + inverters: overlayTopology( + hasInverterData + ? cfgInverters + : buildInvertersFromPreset( + props.values.Config.MaximumChargingCurrent, + props.values.Config.MaximumDischargingCurrent, + ) + ), operatingPriority: resolveOperatingPriorityIndex( props.values.Config.OperatingPriority ), @@ -179,6 +238,40 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { // Fingerprint S3 Config for change detection (not value comparison) const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config); + // Overlay Information-tab-derived topology onto a `inverters` config object. + // Battery counts come from the SN tree (filled SNs); PvCount comes from pvStringsPerInverter. + // Existing per-cluster current limits are preserved. + const overlayTopology = ( + inverters: { [k: string]: InverterConfig } | undefined + ): { [k: string]: InverterConfig } | undefined => { + if (!presetConfig) return inverters; + const tree = parseBatterySnTree(props.installation.batterySerialNumbers || '', presetConfig); + const pvStrings = (props.installation.pvStringsPerInverter || '') + .split(',') + .map((s) => s.trim()); + const out: { [k: string]: InverterConfig } = {}; + presetConfig.forEach((clusters, invIdx) => { + const invKey = `Inverter${invIdx + 1}`; + const existingInv = inverters?.[invKey]; + const cls: { [k: string]: any } = {}; + clusters.forEach((_slotCount, clIdx) => { + const clKey = `Cluster${clIdx + 1}`; + const filled = (tree[invIdx]?.[clIdx] ?? []).filter((s) => s !== '').length; + const existingCl = existingInv?.Clusters?.[clKey]; + cls[clKey] = { + BatteryCount: filled, + MaxChargingCurrent: existingCl?.MaxChargingCurrent ?? 0, + MaxDischargingCurrent: existingCl?.MaxDischargingCurrent ?? 0, + }; + }); + out[invKey] = { + Clusters: cls, + PvCount: parseInt(pvStrings[invIdx] || '0', 10) || 0, + }; + }); + return out; + }; + // Initialize form from localStorage (if pending submit exists) or from S3 // This runs in the useState initializer so the component never renders stale values const [formValues, setFormValues] = useState>(() => { @@ -266,17 +359,23 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { if (!validateTimeOnly()) { return; } + // Re-overlay Information-tab topology at submit time, so battery counts and PV count + // are always the latest from the Information tab (it's the source of truth). + const inverters = overlayTopology(formValues.inverters); + // Pull the first cluster's value as a legacy single-scalar fallback for older firmware. + const firstInvKey = inverters ? Object.keys(inverters)[0] : undefined; + const firstClusterKey = firstInvKey && inverters + ? Object.keys(inverters[firstInvKey].Clusters)[0] + : undefined; + const firstCluster = firstInvKey && firstClusterKey && inverters + ? inverters[firstInvKey].Clusters[firstClusterKey] + : undefined; const configurationToSend: Partial = { minimumSoC: formValues.minimumSoC, - maximumDischargingCurrent: formValues.maximumDischargingCurrent, - maximumChargingCurrent: formValues.maximumChargingCurrent, + maximumDischargingCurrent: firstCluster?.MaxDischargingCurrent ?? formValues.maximumDischargingCurrent, + maximumChargingCurrent: firstCluster?.MaxChargingCurrent ?? formValues.maximumChargingCurrent, + inverters, operatingPriority: formValues.operatingPriority, - inverterNumber: formValues.inverterNumber, - batteriesCountPerInverter: formValues.batteriesCountPerInverter, - batteriesCount: formValues.batteriesCountPerInverter?.[0] ?? formValues.batteriesCount, - clusterNumber:formValues.clusterNumber, - PvNumber:formValues.PvNumber, - pvCountPerInverter: formValues.pvCountPerInverter, timeChargeandDischargePower: formValues.timeChargeandDischargePower, startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime ? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000) @@ -470,15 +569,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} {device === 4 && (() => { - // Mirror Information tab's preset derivation so both views always agree. - const isSodistorePro = product === 5; - const model = props.installation.installationModel; - const presetConfig: PresetConfig | null = isSodistorePro - ? (model && parseInt(model, 10) > 0 - ? buildSodistoreProPreset(parseInt(model, 10)) - : null) - : (getPresetsForDevice(device)[model] || null); - const inverterCount = presetConfig?.length ?? 1; + // Read the SN tree from the Information tab data (single source of truth). + // Filled batteries per cluster = entries with a non-empty serial number. + const tree: BatterySnTree | null = presetConfig + ? parseBatterySnTree(props.installation.batterySerialNumbers || '', presetConfig) + : null; + // PV strings per inverter — comma-separated string from Information tab. + const pvStrings = (props.installation.pvStringsPerInverter || '') + .split(',') + .map((s) => s.trim()); return ( <> @@ -492,21 +591,70 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { {Array.from({ length: inverterCount }, (_, i) => { - const batteriesInInverter = presetConfig - ? (presetConfig[i] ?? []).reduce((a, b) => a + b, 0) - : 0; + const clusters = presetConfig?.[i] ?? []; + const treeForInverter = tree?.[i] ?? []; + const filledBat = treeForInverter.flat().filter((s) => s !== '').length; + const totalBat = clusters.reduce((a, b) => a + b, 0); return ( -
- -
+ + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + + {clusters.map((_slotCount, clIdx) => { + const filledInCluster = (treeForInverter[clIdx] ?? []) + .filter((s) => s !== '').length; + return ( +
+ +
+ ); + })} + {(() => { + const pvCount = parseInt(pvStrings[i] || '0', 10) || 0; + return ( +
+ +
+ ); + })()} +
+
); })} @@ -556,25 +704,106 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { -
- -
+ {device === 4 ? ( + // Per-cluster, per-inverter charging/discharging current limits — nested config. + Array.from({ length: inverterCount }, (_, invIdx) => { + const invKey = `Inverter${invIdx + 1}`; + const clusters = presetConfig?.[invIdx] ?? [0]; + return ( + + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + {clusters.map((_slotCount, clIdx) => { + const clKey = `Cluster${clIdx + 1}`; + const cluster = formValues.inverters?.[invKey]?.Clusters?.[clKey]; + const charge = cluster?.MaxChargingCurrent ?? ''; + const discharge = cluster?.MaxDischargingCurrent ?? ''; + const setClusterField = ( + field: 'MaxChargingCurrent' | 'MaxDischargingCurrent', + v: string + ) => { + if (v !== '' && !/^\d*\.?\d*$/.test(v)) return; + setFormDirty(true); + setFormValues((prev) => { + const inverters = { ...(prev.inverters ?? {}) }; + const inv = inverters[invKey] + ?? { Clusters: {}, PvCount: 0 }; + const cls = { ...(inv.Clusters ?? {}) }; + const existing = cls[clKey] ?? { + BatteryCount: presetConfig?.[invIdx]?.[clIdx] ?? 0, + MaxChargingCurrent: 0, + MaxDischargingCurrent: 0, + }; + cls[clKey] = { + ...existing, + [field]: v === '' ? 0 : parseFloat(v), + }; + inverters[invKey] = { ...inv, Clusters: cls }; + return { ...prev, inverters }; + }); + }; + return ( +
+ + + + setClusterField('MaxChargingCurrent', e.target.value)} + fullWidth + /> + setClusterField('MaxDischargingCurrent', e.target.value)} + fullWidth + /> +
+ ); + })} +
+
+ ); + }) + ) : ( + <> +
+ +
-
- -
+
+ +
+ + )} {device === 4 && ( <> @@ -802,16 +1031,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
{ - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) { - setFormDirty(true); - setFormValues((prev) => ({ ...prev, currentPrice: v })); - } - }} + value={(props.values.Config as any).CurrentPrice?.toString() ?? ''} InputProps={{ + readOnly: true, endAdornment: CHF/kWh, }} fullWidth diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 99c22feee..9522c3842 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -521,7 +521,12 @@ "enterPowerValue": "Positiven oder negativen Leistungswert eingeben", "inverterNumber": "Anzahl Wechselrichter", "batteriesCountInInverter": "Batterieanzahl in Wechselrichter {number}", + "batteryNumberInClusterN": "Batterieanzahl in Cluster {n}", + "pvStringsNumberInInverterN": "Anzahl PV-Strings in Wechselrichter {n}", + "batteries": "Batterien", "maximumChargingCurrentPerBattery": "Maximaler Ladestrom pro Batterie (A)", + "maximumChargingCurrentPerClusterLabel": "Maximaler Ladestrom pro Cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Maximaler Entladestrom pro Cluster (A)", "maximumDischargingCurrentPerBattery": "Maximaler Entladestrom pro Batterie (A)", "powerPerInverterKW": "Leistung pro Wechselrichter (kW)", "startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index f944eb822..8622a1357 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -269,8 +269,13 @@ "enterPowerValue": "Enter a positive or negative power value", "inverterNumber": "Inverter Number", "batteriesCountInInverter": "Batteries Count in Inverter {number}", + "batteryNumberInClusterN": "Battery Number in Cluster {n}", + "pvStringsNumberInInverterN": "PV Strings Number in Inverter {n}", + "batteries": "batteries", "maximumChargingCurrentPerBattery": "Maximum Charging Current per Battery (A)", "maximumDischargingCurrentPerBattery": "Maximum Discharging Current per Battery (A)", + "maximumChargingCurrentPerClusterLabel": "Maximum Charging Current per Cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Maximum Discharging Current per Cluster (A)", "powerPerInverterKW": "Power per Inverter (kW)", "startDateTime": "Start Date and Time (Start Time < Stop Time)", "stopDateTime": "Stop Date and Time (Start Time < Stop Time)", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 279e00d8a..6330f9206 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -521,7 +521,12 @@ "enterPowerValue": "Entrez une valeur de puissance positive ou négative", "inverterNumber": "Nombre d'onduleurs", "batteriesCountInInverter": "Nombre de batteries dans l'onduleur {number}", + "batteryNumberInClusterN": "Nombre de batteries dans le cluster {n}", + "pvStringsNumberInInverterN": "Nombre de chaînes PV dans l'onduleur {n}", + "batteries": "batteries", "maximumChargingCurrentPerBattery": "Courant de charge maximum par batterie (A)", + "maximumChargingCurrentPerClusterLabel": "Courant de charge maximum par cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Courant de décharge maximum par cluster (A)", "maximumDischargingCurrentPerBattery": "Courant de décharge maximum par batterie (A)", "powerPerInverterKW": "Puissance par onduleur (kW)", "startDateTime": "Date et heure de début (Début < Fin)", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index fea28ad13..0855d1158 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -521,7 +521,12 @@ "enterPowerValue": "Inserire un valore di potenza positivo o negativo", "inverterNumber": "Numero di inverter", "batteriesCountInInverter": "Numero di batterie nell'inverter {number}", + "batteryNumberInClusterN": "Numero di batterie nel cluster {n}", + "pvStringsNumberInInverterN": "Numero di stringhe PV nell'inverter {n}", + "batteries": "batterie", "maximumChargingCurrentPerBattery": "Corrente massima di carica per batteria (A)", + "maximumChargingCurrentPerClusterLabel": "Corrente massima di carica per cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Corrente massima di scarica per cluster (A)", "maximumDischargingCurrentPerBattery": "Corrente massima di scarica per batteria (A)", "powerPerInverterKW": "Potenza per inverter (kW)", "startDateTime": "Data e ora di inizio (Inizio < Fine)",