new updates again

This commit is contained in:
Yinyin Liu 2026-04-29 09:34:35 +02:00
parent 2889d4c281
commit be8c7d69b2
7 changed files with 332 additions and 60 deletions

View File

@ -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<string, InverterConfig>? Inverters { get; set; }
public double? OperatingPriority { get; set; }
public int? InverterNumber { get; set; }
public double? BatteriesCount { get; set; }
@ -78,3 +81,16 @@ public enum CalibrationChargeType
AdditionallyOnce,
ChargePermanently
}
public class InverterConfig
{
public Dictionary<string, ClusterConfig> 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; }
}

View File

@ -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;

View File

@ -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<ConfigurationValues> => {
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<Partial<ConfigurationValues>>(() => {
@ -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<ConfigurationValues> = {
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) {
</div>
{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 (
<div key={`battCount_${i}`} style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage(
{ id: 'batteriesCountInInverter' },
{ number: i + 1 }
)}
value={batteriesInInverter}
InputProps={{ readOnly: true }}
fullWidth
/>
</div>
<Accordion
key={`inv-${i}`}
defaultExpanded={false}
sx={{ ml: 1, mr: 1, mt: 1 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
justifyContent: 'flex-start',
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
}}
>
<Typography sx={{ fontWeight: 'bold' }}>
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: i + 1 }} />
</Typography>
<Chip
label={`${filledBat} ${intl.formatMessage({ id: 'batteries', defaultMessage: 'batteries' })}`}
size="small"
variant="outlined"
sx={{ ml: 1 }}
/>
</AccordionSummary>
<AccordionDetails>
{clusters.map((_slotCount, clIdx) => {
const filledInCluster = (treeForInverter[clIdx] ?? [])
.filter((s) => s !== '').length;
return (
<div key={`cl-${i}-${clIdx}`} style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage(
{ id: 'batteryNumberInClusterN', defaultMessage: 'Battery Number in Cluster {n}' },
{ n: clIdx + 1 }
)}
value={filledInCluster}
InputProps={{ readOnly: true }}
fullWidth
/>
</div>
);
})}
{(() => {
const pvCount = parseInt(pvStrings[i] || '0', 10) || 0;
return (
<div key={`pv-${i}`} style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage(
{ id: 'pvStringsNumberInInverterN', defaultMessage: 'PV Strings Number in Inverter {n}' },
{ n: i + 1 }
)}
value={pvCount}
InputProps={{ readOnly: true }}
fullWidth
/>
</div>
);
})()}
</AccordionDetails>
</Accordion>
);
})}
</>
@ -556,25 +704,106 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })}
name="maximumChargingCurrent"
value={formValues.maximumChargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
{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 (
<Accordion
key={`limits-${invKey}`}
defaultExpanded={false}
sx={{ ml: 1, mr: 1, mt: 1 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
justifyContent: 'flex-start',
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
}}
>
<Typography sx={{ fontWeight: 'bold' }}>
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: invIdx + 1 }} />
</Typography>
</AccordionSummary>
<AccordionDetails>
{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 (
<div key={`cl-limits-${invKey}-${clKey}`} style={{ marginBottom: '5px' }}>
<Typography variant="subtitle2" sx={{ mt: 1, ml: 1 }}>
<FormattedMessage id="clusterN" defaultMessage="Cluster {n}" values={{ n: clIdx + 1 }} />
</Typography>
<TextField
label={intl.formatMessage({ id: 'maximumChargingCurrentPerClusterLabel' })}
value={charge}
onChange={(e) => setClusterField('MaxChargingCurrent', e.target.value)}
fullWidth
/>
<TextField
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerClusterLabel' })}
value={discharge}
onChange={(e) => setClusterField('MaxDischargingCurrent', e.target.value)}
fullWidth
/>
</div>
);
})}
</AccordionDetails>
</Accordion>
);
})
) : (
<>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })}
name="maximumChargingCurrent"
value={formValues.maximumChargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })}
name="maximumDischargingCurrent"
value={formValues.maximumDischargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })}
name="maximumDischargingCurrent"
value={formValues.maximumDischargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
</>
)}
{device === 4 && (
<>
@ -802,16 +1031,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'currentPrice' })}
name="currentPrice"
value={formValues.currentPrice ?? ''}
onChange={(e) => {
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: <InputAdornment position="end">CHF/kWh</InputAdornment>,
}}
fullWidth

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",