Merge branch 'feature/sodistoregrid-live-view' into main

This commit is contained in:
Yinyin Liu 2026-05-19 16:25:26 +02:00
commit 8b8fe8cf2e
13 changed files with 935 additions and 156 deletions

View File

@ -45,14 +45,36 @@ function BatteryView(props: BatteryViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { product, setProduct } = useContext(ProductIdContext); const { product, setProduct } = useContext(ProductIdContext);
const sortedBatteryView = // SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
props.values != null && props.values?.Battery?.Devices // Flatten into a single list with composite IDs "{cluster}-{device}" so the existing
? Object.entries(props.values.Battery.Devices) // BatteryView table renders without further changes.
.map(([BatteryId, battery]) => { const sortedBatteryView = (() => {
return { BatteryId, battery }; // Here we return an object with the id and device if (props.values == null) return [];
})
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId)) if (
: []; product === 4 &&
props.values.ListOfBatteriesRecord
) {
const flat: { BatteryId: string; battery: any }[] = [];
Object.entries(props.values.ListOfBatteriesRecord).forEach(
([clusterId, cluster]: [string, any]) => {
if (cluster?.Devices) {
Object.entries(cluster.Devices).forEach(([devId, dev]) => {
flat.push({ BatteryId: `${clusterId}-${devId}`, battery: dev });
});
}
}
);
return flat.sort((a, b) => a.BatteryId.localeCompare(b.BatteryId));
}
if (props.values?.Battery?.Devices) {
return Object.entries(props.values.Battery.Devices)
.map(([BatteryId, battery]) => ({ BatteryId, battery }))
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId));
}
return [];
})();
const [loading, setLoading] = useState(sortedBatteryView.length == 0); const [loading, setLoading] = useState(sortedBatteryView.length == 0);
@ -177,39 +199,37 @@ function BatteryView(props: BatteryViewProps) {
} }
/> />
{product === 0 {product === 0
? Object.entries(props.values.Battery.Devices).map( ? sortedBatteryView.map(({ BatteryId, battery }) => (
([BatteryId, battery]) => ( <Route
<Route key={routes.detailed_view + BatteryId}
key={routes.detailed_view + BatteryId} path={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId} element={
element={ <DetailedBatteryView
<DetailedBatteryView batteryId={Number(BatteryId)}
batteryId={Number(BatteryId)} s3Credentials={props.s3Credentials}
s3Credentials={props.s3Credentials} batteryData={battery}
batteryData={battery} installationId={props.installationId}
installationId={props.installationId} productNum={product}
productNum={product} ></DetailedBatteryView>
></DetailedBatteryView> }
} />
/> ))
) : sortedBatteryView.map(({ BatteryId, battery }) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryViewSodistore
// Keep BatteryId as-is (Number("1-1") === NaN for product 4).
batteryId={BatteryId}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryViewSodistore>
}
/>
) )
: Object.entries(props.values.Battery.Devices).map(
([BatteryId, battery]) => (
<Route
key={routes.detailed_view + BatteryId}
path={routes.detailed_view + BatteryId}
element={
<DetailedBatteryViewSodistore
batteryId={Number(BatteryId)}
s3Credentials={props.s3Credentials}
batteryData={battery}
installationId={props.installationId}
productNum={product}
></DetailedBatteryViewSodistore>
}
/>
)
)} )}
</Routes> </Routes>
</Grid> </Grid>
@ -262,7 +282,7 @@ function BatteryView(props: BatteryViewProps) {
component="th" component="th"
scope="row" scope="row"
align="center" align="center"
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }}
> >
<Link <Link
style={{ color: 'black' }} style={{ color: 'black' }}

View File

@ -19,7 +19,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import GaugeChart from 'react-gauge-chart'; import GaugeChart from 'react-gauge-chart';
interface DetailedBatteryViewSodistoreProps { interface DetailedBatteryViewSodistoreProps {
batteryId: number; // SodistoreGrid uses composite "{cluster}-{device}" IDs (e.g. "1-1"),
// so this is intentionally widened — the value is only rendered, never Number()'d.
batteryId: number | string;
s3Credentials: I_S3Credentials; s3Credentials: I_S3Credentials;
batteryData: Device; batteryData: Device;
installationId: number; installationId: number;

View File

@ -134,34 +134,22 @@ function MainStats(props: MainStatsProps) {
function generateSeries(chartData, category, color) { function generateSeries(chartData, category, color) {
const series = []; const series = [];
const pathsToSearch = [ // Use all actually-present series keys so product 4 (SodiStoreGrid)
'Node0', // composite names like "Node1-1".."Node3-6" are picked up too.
'Node1', const presentPaths = Object.keys(chartData[category]?.data ?? {}).sort();
'Node2',
'Node3',
'Node4',
'Node5',
'Node6',
'Node7',
'Node8',
'Node9',
'Node10'
];
let i = 0; let i = 0;
pathsToSearch.forEach((devicePath) => { presentPaths.forEach((devicePath) => {
if ( if (chartData[category].data[devicePath]?.data?.length) {
Object.hasOwnProperty.call(chartData[category].data, devicePath) && const palette =
chartData[category].data[devicePath].data.length != 0 color === 'blue'
) { ? blueColors
: color === 'red'
? redColors
: orangeColors;
series.push({ series.push({
...chartData[category].data[devicePath], ...chartData[category].data[devicePath],
color: color: palette[i % palette.length]
color === 'blue'
? blueColors[i]
: color === 'red'
? redColors[i]
: orangeColors[i]
}); });
} }
i++; i++;

View File

@ -0,0 +1,256 @@
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
import {
Alert,
Box,
CardContent,
CircularProgress,
Container,
Grid,
IconButton,
TextField,
useTheme
} from '@mui/material';
import React, { useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import axiosConfig from '../../../Resources/axiosConfig';
interface ConfigurationSodistoreGridProps {
values: JSONRecordData;
id: number;
}
function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
const intl = useIntl();
if (props.values === null) {
return null;
}
const theme = useTheme();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
const [errors, setErrors] = useState({
minimumSoC: false,
gridSetPoint: false
});
const SetErrorForField = (field_name: string, state: boolean) => {
setErrors((prevErrors) => ({
...prevErrors,
[field_name]: state
}));
};
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>({
minimumSoC: props.values.Config?.MinSoc,
gridSetPoint:
props.values.Config?.GridSetPoint != null
? (props.values.Config.GridSetPoint as number) / 1000
: undefined
});
const handleChange = (e) => {
const { name, value } = e.target;
switch (name) {
case 'minimumSoC':
if (
/[^0-9.]/.test(value) ||
isNaN(parseFloat(value)) ||
parseFloat(value) > 100
) {
SetErrorForField(name, true);
} else {
SetErrorForField(name, false);
}
break;
case 'gridSetPoint':
if (/[^0-9.]/.test(value) || isNaN(parseFloat(value))) {
SetErrorForField(name, true);
} else {
SetErrorForField(name, false);
}
break;
default:
return true;
}
setFormValues({
...formValues,
[name]: value
});
};
const handleSubmit = async () => {
const configurationToSend: Partial<ConfigurationValues> = {
minimumSoC: formValues.minimumSoC,
gridSetPoint: formValues.gridSetPoint
};
setLoading(true);
const res = await axiosConfig
.post(
`/EditInstallationConfig?installationId=${props.id}&product=4`,
configurationToSend
)
.catch((err) => {
if (err.response) {
setError(true);
setLoading(false);
}
});
if (res) {
setUpdated(true);
setLoading(false);
}
};
return (
<Container maxWidth="xl">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
<Grid item xs={12} md={12}>
<CardContent>
<Box
component="form"
sx={{
'& .MuiTextField-root': { m: 1, width: 390 }
}}
noValidate
autoComplete="off"
>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="minimum_soc "
defaultMessage="Minimum SoC (%)"
/>
}
name="minimumSoC"
value={formValues.minimumSoC ?? ''}
onChange={handleChange}
helperText={
errors.minimumSoC ? (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'valueBetween0And100' })}
</span>
) : (
''
)
}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="grid_set_point"
defaultMessage="Grid Set Point (kW)"
/>
}
name="gridSetPoint"
value={formValues.gridSetPoint ?? ''}
onChange={handleChange}
helperText={
errors.gridSetPoint ? (
<span style={{ color: 'red' }}>
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
</span>
) : (
''
)
}
fullWidth
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
disabled={errors.minimumSoC || errors.gridSetPoint}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage
id="applychanges"
defaultMessage="Apply Changes"
/>
</Button>
{loading && (
<CircularProgress
sx={{
color: theme.palette.primary.main,
marginLeft: '20px'
}}
/>
)}
{updated && (
<Alert
severity="success"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="successfullyAppliedConfig"
defaultMessage="Successfully applied configuration file"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setUpdated(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
{error && (
<Alert
severity="error"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="configErrorOccurred"
defaultMessage="An error has occurred"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</CardContent>
</Grid>
</Grid>
</Container>
);
}
export default ConfigurationSodistoreGrid;

View File

@ -25,9 +25,10 @@ import Information from '../Information/Information';
import { UserType } from '../../../interfaces/UserTypes'; import { UserType } from '../../../interfaces/UserTypes';
import HistoryOfActions from '../History/History'; import HistoryOfActions from '../History/History';
import Topology from '../Topology/Topology'; import Topology from '../Topology/Topology';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import TopologySodistoreGrid from '../Topology/TopologySodistoreGrid';
import BatteryView from '../BatteryView/BatteryView'; import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration'; import Configuration from '../Configuration/Configuration';
import ConfigurationSodistoreGrid from '../Configuration/ConfigurationSodistoreGrid';
import PvView from '../PvView/PvView'; import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab'; import DocumentsTab from '../Documents/DocumentsTab';
@ -51,6 +52,9 @@ function Installation(props: singleInstallationProps) {
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
const [values, setValues] = useState<JSONRecordData | null>(null); const [values, setValues] = useState<JSONRecordData | null>(null);
const status = props.current_installation.status; const status = props.current_installation.status;
// For SodiStoreGrid (product 4), backend heartbeat path is broken (SinexcelCommunication
// hardcodes Product=2), so installation.status stays -1 even when S3 is fresh.
// Treat as Green when our S3 fetch succeeded (connected === true).
const [connected, setConnected] = useState(true); const [connected, setConnected] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -80,11 +84,61 @@ function Installation(props: singleInstallationProps) {
//While fetching, we check the value of continueFetching, if its false, we break. This means that either the user changed tab or the object has been finished its execution (return) //While fetching, we check the value of continueFetching, if its false, we break. This means that either the user changed tab or the object has been finished its execution (return)
const continueFetching = useRef(false); const continueFetching = useRef(false);
// SodiStoreGrid (Sinexcel) uploads chunked files every ~150s with arbitrary
// unix-second filenames (read key denies LIST — only GET is allowed).
// Probe a wide window in parallel batches with 1-second step.
// `checkContinue` lets the loop short-circuit when the user leaves Live,
// bounding wasted in-flight GETs to a single 20-request batch.
const probeLatestGridChunk = async (
maxAgeSeconds: number = 600,
checkContinue: () => boolean = () => true
): Promise<{ res: any; ts: any } | null> => {
const batchSize = 20;
const step = 1;
for (let batchStart = 0; batchStart < maxAgeSeconds; batchStart += batchSize * step) {
if (!checkContinue()) return null;
const offsets: number[] = [];
for (let j = 0; j < batchSize; j++) {
const offset = batchStart + j * step;
if (offset < maxAgeSeconds) offsets.push(offset);
}
const now = UnixTime.now();
const results = await Promise.all(
offsets.map(async (offset) => {
const ts = now.earlier(TimeSpan.fromSeconds(offset));
const r = await fetchDataJson(ts, s3Credentials, false);
return r !== FetchResult.notAvailable && r !== FetchResult.tryLater
? { res: r, ts }
: null;
})
);
const hit = results.find((r) => r !== null);
if (hit) return hit;
}
return null;
};
const fetchDataForOneTime = async () => { const fetchDataForOneTime = async () => {
var timeperiodToSearch = 70; var timeperiodToSearch = 70;
let res; let res;
let timestampToFetch; let timestampToFetch;
if (props.current_installation.product === 4) {
const hit = await probeLatestGridChunk(600);
if (!hit) {
setConnected(false);
setLoading(false);
return false;
}
setConnected(true);
setLoading(false);
const timestamps = Object.keys(hit.res).sort(
(a, b) => Number(a) - Number(b)
);
setValues(hit.res[timestamps[timestamps.length - 1]]);
return true;
}
for (var i = timeperiodToSearch; i > 0; i -= 2) { for (var i = timeperiodToSearch; i > 0; i -= 2) {
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i)); timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
try { try {
@ -122,6 +176,55 @@ function Installation(props: singleInstallationProps) {
let res; let res;
let timestampToFetch; let timestampToFetch;
// SodiStoreGrid: probe a wide window in parallel (read key denies LIST),
// stream through that chunk's timestamps, then refresh.
// Backoff schedule on consecutive misses to cap S3 cost on offline installs.
if (props.current_installation.product === 4) {
let firstHit = false;
let consecutiveMisses = 0;
const backoffMs = [30000, 60000, 120000, 300000]; // 30s → 60s → 2m → 5m (cap)
while (continueFetching.current) {
// Narrow window after first hit; widen back if we lose it.
const window = firstHit && consecutiveMisses === 0 ? 200 : 600;
const hit = await probeLatestGridChunk(
window,
() => continueFetching.current
);
if (!continueFetching.current) break;
if (!hit) {
consecutiveMisses += 1;
// Always reflect disconnection in the UI — even after a prior hit,
// so the user sees a stale-data signal instead of a frozen chart.
setConnected(false);
if (!firstHit) setLoading(false);
const wait = backoffMs[Math.min(consecutiveMisses - 1, backoffMs.length - 1)];
await timeout(wait);
continue;
}
consecutiveMisses = 0;
if (!firstHit) {
firstHit = true;
setLoading(false);
}
setConnected(true);
// Stream through chunk timestamps in ascending order (chunk = ~15 records, ~10s apart)
const orderedTs = Object.keys(hit.res).sort(
(a, b) => Number(a) - Number(b)
);
for (const t of orderedTs) {
if (!continueFetching.current) {
setFetchFunctionCalled(false);
return false;
}
setValues(hit.res[t]);
await timeout(2000);
}
await timeout(30000);
}
setFetchFunctionCalled(false);
return false;
}
for (var i = 0; i < timeperiodToSearch; i += 2) { for (var i = 0; i < timeperiodToSearch; i += 2) {
if (!continueFetching.current) { if (!continueFetching.current) {
return false; return false;
@ -194,12 +297,14 @@ function Installation(props: singleInstallationProps) {
setCurrentTab(path[path.length - 1]); setCurrentTab(path[path.length - 1]);
}, [location]); }, [location]);
//If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component //If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component.
//SodiStoreGrid (product 4) is excluded: backend heartbeat path is broken
//because SinexcelCommunication hardcodes Product=2 — trust S3 freshness instead.
useEffect(() => { useEffect(() => {
if (status === -1) { if (status === -1 && props.current_installation.product !== 4) {
setConnected(false); setConnected(false);
} }
}, [status]); }, [status, props.current_installation.product]);
useEffect(() => { useEffect(() => {
if ( if (
@ -276,7 +381,7 @@ function Installation(props: singleInstallationProps) {
</Typography> </Typography>
</div> </div>
{currentTab == 'live' && values && ( {currentTab == 'live' && values && values.EssControl?.Mode && (
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Typography <Typography
fontWeight="bold" fontWeight="bold"
@ -326,7 +431,7 @@ function Installation(props: singleInstallationProps) {
marginTop: '-10px' marginTop: '-10px'
}} }}
> >
{status === -1 ? ( {status === -1 && !(props.current_installation.product === 4 && connected) ? (
<CancelIcon <CancelIcon
style={{ style={{
width: '23px', width: '23px',
@ -361,7 +466,8 @@ function Installation(props: singleInstallationProps) {
? 'red' ? 'red'
: status === 1 : status === 1
? 'orange' ? 'orange'
: status === -1 || status === -2 : (status === -1 || status === -2) &&
!(props.current_installation.product === 4 && connected)
? 'transparent' ? 'transparent'
: 'green' : 'green'
}} }}
@ -469,12 +575,11 @@ function Installation(props: singleInstallationProps) {
path={routes.live} path={routes.live}
element={ element={
props.current_installation.product === 4 ? ( props.current_installation.product === 4 ? (
<TopologySodistoreHome <TopologySodistoreGrid
values={values} values={values}
connected={connected} connected={connected}
loading={loading} loading={loading}
batteryClusterNumber={props.current_installation.batteryClusterNumber} ></TopologySodistoreGrid>
></TopologySodistoreHome>
) : ( ) : (
<Topology <Topology
values={values} values={values}
@ -501,20 +606,10 @@ function Installation(props: singleInstallationProps) {
path={routes.configuration} path={routes.configuration}
element={ element={
props.current_installation.product === 4 ? ( props.current_installation.product === 4 ? (
// TODO: SodistoreGrid — implement actual configuration <ConfigurationSodistoreGrid
<Container values={values}
maxWidth="xl" id={props.current_installation.id}
sx={{ />
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '40vh'
}}
>
<Typography variant="body1" color="text.secondary">
Configuration not yet available
</Typography>
</Container>
) : ( ) : (
<Configuration <Configuration
values={values} values={values}

View File

@ -106,9 +106,9 @@ function InstallationTabs(props: InstallationTabsProps) {
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready // TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
const hidePvView = props.product === 4; const hidePvView = props.product === 4;
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view. // Checklist is not shown for any product in the Installations view.
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows. // Salimax (0) / Salidomo (1) / SodistoreMax (3) / SodistoreGrid (4) use different onboarding flows.
const showChecklist = props.product === 4; const showChecklist = false;
const singleInstallationTabs = ( const singleInstallationTabs = (
currentUser.userType == UserType.admin currentUser.userType == UserType.admin

View File

@ -594,6 +594,19 @@ export interface JSONRecordData {
// [PvId: string]: PvString; // [PvId: string]: PvString;
// }; // };
// }; // };
// For SodistoreGrid: list of battery clusters keyed by 1-based string IDs
ListOfBatteriesRecord?: {
[clusterId: string]: {
Soc: number;
Soh: number;
Voltage: number;
Current: number;
Power: number;
TemperatureCell1: number;
[key: string]: any;
};
};
} }
export const parseChunkJson = ( export const parseChunkJson = (
@ -1174,7 +1187,9 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
'InverterRecord.TotalBatteryPower', 'InverterRecord.TotalBatteryPower',
'InverterRecord.TotalPhotovoltaicPower', 'InverterRecord.TotalPhotovoltaicPower',
'InverterRecord.TotalLoadPower', 'InverterRecord.TotalLoadPower',
'InverterRecord.TotalGridPower' 'InverterRecord.TotalGridPower',
'InverterRecord.ActivePowerW',
'DcDc.Dc.Battery.Power'
]; ];
// Helper function to safely get a value from a nested path // Helper function to safely get a value from a nested path

View File

@ -7,7 +7,9 @@ export const getChartOptions = (
type: string, type: string,
dateList: string[], dateList: string[],
stacked: Boolean, stacked: Boolean,
voltageInfo?: chartInfoInterface voltageInfo?: chartInfoInterface,
powerLabel?: string,
temperatureInfo?: chartInfoInterface
): ApexOptions => { ): ApexOptions => {
return type.includes('daily') return type.includes('daily')
? { ? {
@ -57,7 +59,7 @@ export const getChartOptions = (
type === 'dailyoverview' type === 'dailyoverview'
? [ ? [
{ {
seriesName: 'Grid Power', seriesName: powerLabel ?? 'Grid Power',
tickAmount: 6, tickAmount: 6,
min: min:
chartInfo.min >= 0 chartInfo.min >= 0
@ -94,7 +96,7 @@ export const getChartOptions = (
} }
}, },
{ {
seriesName: 'Grid Power', seriesName: powerLabel ?? 'Grid Power',
show: false, show: false,
tickAmount: 6, tickAmount: 6,
min: min:
@ -123,7 +125,7 @@ export const getChartOptions = (
} }
}, },
{ {
seriesName: 'Grid Power', seriesName: powerLabel ?? 'Grid Power',
show: false, show: false,
tickAmount: 6, tickAmount: 6,
min: min:
@ -192,6 +194,27 @@ export const getChartOptions = (
return Math.round(value).toString(); return Math.round(value).toString();
} }
} }
}] : []),
...(temperatureInfo ? [{
seriesName: 'Battery Temperature',
opposite: true,
tickAmount: 5,
min: Math.floor((temperatureInfo.min - 5) / 5) * 5,
max: Math.ceil((temperatureInfo.max + 5) / 5) * 5,
title: {
text: '(°C)',
style: {
fontSize: '12px'
},
offsetY: -190,
offsetX: -65,
rotate: 0
},
labels: {
formatter: function (value: number) {
return Math.round(value).toString();
}
}
}] : []) }] : [])
] ]
: { : {
@ -241,18 +264,27 @@ export const getChartOptions = (
}, },
y: { y: {
formatter: function (val, { seriesIndex, w }) { formatter: function (val, { seriesIndex, w }) {
// `shared: true` calls this for every series at the hovered x,
// even when a particular series has no data point there → val undefined.
if (val === undefined || val === null || Number.isNaN(val)) {
return '-';
}
const seriesName = w.config.series[seriesIndex].name; const seriesName = w.config.series[seriesIndex].name;
if (seriesName === 'Battery SOC') { if (seriesName === 'Battery SOC') {
return val.toFixed(2) + ' %'; return val.toFixed(2) + ' %';
} else if (seriesName === 'Battery Voltage') { } else if (seriesName === 'Battery Voltage') {
return val.toFixed(2) + ' (V)'; return val.toFixed(2) + ' (V)';
} else if (seriesName === 'Battery Temperature') {
return val.toFixed(2) + ' (°C)';
} else { } else {
const formatted = formatPowerForGraph(val, chartInfo.magnitude);
const raw = formatted?.value;
return ( return (
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed( (raw === undefined || raw === null || Number.isNaN(raw)
2 ? '-'
) + : raw.toFixed(2)) +
' ' + ' ' +
chartInfo.unit (chartInfo.unit ?? '')
); );
} }
} }

View File

@ -566,7 +566,7 @@ function Overview(props: OverviewProps) {
> >
<FormattedMessage id="24_hours" defaultMessage="24-hours" /> <FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button> </Button>
{props.device !== 3 && props.product !== 2 && ( {props.device !== 3 && props.product !== 2 && props.product !== 4 && (
<Button <Button
variant="contained" variant="contained"
onClick={handleWeekData} onClick={handleWeekData}
@ -663,7 +663,7 @@ function Overview(props: OverviewProps) {
</Container> </Container>
)} )}
{!loading && dailyDataArray.length > 0 && ( {!loading && ((dailyData && dailyDataArray.length > 0) || (aggregatedData && aggregatedDataArray.length > 0)) && (
<Grid item xs={12} md={12}> <Grid item xs={12} md={12}>
{dailyData && ( {dailyData && (
<Grid <Grid
@ -709,11 +709,22 @@ function Overview(props: OverviewProps) {
<ReactApexChart <ReactApexChart
options={{ options={{
...getChartOptions( ...getChartOptions(
dailyDataArray[chartState].chartOverview.overview, // For SodiStoreGrid (product 4), the "overview" yaxis
// bucket is unused — drive the left power axis from
// Battery Power instead so SOC matches its % axis correctly.
product === 4
? dailyDataArray[chartState].chartOverview.dcPower
: dailyDataArray[chartState].chartOverview.overview,
'dailyoverview', 'dailyoverview',
[], [],
true, true,
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined (product === 2 || product === 5 || product === 4)
? dailyDataArray[chartState].chartOverview.batteryVoltage
: undefined,
product === 4 ? 'Battery Power' : undefined,
product === 4
? dailyDataArray[chartState].chartOverview.temperature
: undefined
), ),
chart: { chart: {
events: { events: {
@ -725,32 +736,64 @@ function Overview(props: OverviewProps) {
} }
}} }}
series={[ series={[
{ // SodiStoreGrid (product 4) has no grid meter, no PV, no AC load.
...dailyDataArray[chartState].chartData.gridPower, ...(product !== 4
type: 'line', ? [
color: '#b30000' {
}, ...dailyDataArray[chartState].chartData
{ .gridPower,
...dailyDataArray[chartState].chartData type: 'line' as const,
.pvProduction, color: '#b30000'
type: 'line', },
color: '#ff9900' {
}, ...dailyDataArray[chartState].chartData
{ .pvProduction,
...dailyDataArray[chartState].chartData.ACLoad, type: 'line' as const,
type: 'line', color: '#ff9900'
color: '#2ecc71' },
}, {
...dailyDataArray[chartState].chartData
.ACLoad,
type: 'line' as const,
color: '#2ecc71'
}
]
: [
// For SodiStoreGrid, replace the empty grid/PV/load
// series with Battery Power (drives the left kW axis).
{
...dailyDataArray[chartState].chartData
.dcPower,
name: 'Battery Power',
type: 'line' as const,
color: '#e67e22'
}
]),
{ {
...dailyDataArray[chartState].chartData.soc, ...dailyDataArray[chartState].chartData.soc,
type: 'line', type: 'line',
color: '#008FFB' color: '#008FFB'
}, },
...((product === 2 || product === 5) ? [{ ...(product === 2 || product === 5 || product === 4
...dailyDataArray[chartState].chartData.batteryVoltage, ? [
type: 'line' as const, {
color: '#9b59b6' ...dailyDataArray[chartState].chartData
}] : []) .batteryVoltage,
type: 'line' as const,
color: '#9b59b6'
}
]
: []),
...(product === 4
? [
{
...dailyDataArray[chartState].chartData
.temperature,
type: 'line' as const,
color: '#16a085'
}
]
: [])
]} ]}
height={420} height={420}
/> />
@ -1215,6 +1258,8 @@ function Overview(props: OverviewProps) {
</Grid> </Grid>
)} )}
{/* PV Production + Grid Power — hidden for SodiStoreGrid (no PV, no grid meter) */}
{product !== 4 && (
<Grid <Grid
container container
direction="row" direction="row"
@ -1403,6 +1448,7 @@ function Overview(props: OverviewProps) {
</Card> </Card>
</Grid> </Grid>
</Grid> </Grid>
)}
{aggregatedData && (product === 2 || product === 5) && ( {aggregatedData && (product === 2 || product === 5) && (
<Grid <Grid
@ -1461,7 +1507,7 @@ function Overview(props: OverviewProps) {
</Grid> </Grid>
)} )}
{dailyData && ( {dailyData && product !== 4 && (
<Grid <Grid
container container
direction="row" direction="row"

View File

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import {
CircularProgress,
Container,
Grid,
Switch,
Typography
} from '@mui/material';
import TopologyColumn from './topologyColumn';
import {
getAmount,
getHighestConnectionValue,
JSONRecordData
} from '../Log/graph.util';
import { FormattedMessage } from 'react-intl';
interface TopologySodistoreGridProps {
values: JSONRecordData;
connected: boolean;
loading: boolean;
}
function TopologySodistoreGrid(props: TopologySodistoreGridProps) {
if (props.values === null && props.connected == true) {
return null;
}
const highestConnectionValue =
props.values != null ? getHighestConnectionValue(props.values) : 0;
const [showValues, setShowValues] = useState(false);
const handleSwitch = () => () => {
setShowValues(!showValues);
};
const isMobile = window.innerWidth <= 1490;
const inv = props.values?.InverterRecord;
const dcdc = props.values?.DcDc;
const clusters = props.values?.ListOfBatteriesRecord ?? {};
const clusterIds = Object.keys(clusters).sort(
(a, b) => Number(a) - Number(b)
);
const acDcPower = Number(inv?.ActivePowerW ?? 0);
const dcLinkPower = Number(dcdc?.Dc?.Link?.Power ?? 0);
const dcLinkVoltage = Number(dcdc?.Dc?.Link?.Voltage ?? 0);
const dcdcBatteryVoltage = Number(dcdc?.Dc?.Battery?.Voltage ?? 0);
const dcdcBatteryPower = Number(dcdc?.Dc?.Battery?.Power ?? 0);
return (
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
<Grid container>
{!props.connected && !props.loading && (
<Container
maxWidth="xl"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '70vh'
}}
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography
variant="body2"
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
<FormattedMessage
id="unableToCommunicate"
defaultMessage="Unable to communicate with the installation"
/>
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
<FormattedMessage
id="pleaseWaitOrRefresh"
defaultMessage="Please wait or refresh the page"
/>
</Typography>
</Container>
)}
{props.connected && (
<>
<Grid
item
xs={12}
md={12}
style={{
marginTop: '10px',
height: '20px',
display: 'flex',
flexDirection: 'row',
alignItems: 'right',
justifyContent: 'right'
}}
>
<div>
<Typography sx={{ marginTop: '5px', marginRight: '20px' }}>
Display Values
</Typography>
<Switch
edge="start"
color="secondary"
onChange={handleSwitch()}
sx={{
'& .MuiSwitch-thumb': {
backgroundColor: 'orange'
},
marginLeft: '20px'
}}
/>
</div>
</Grid>
<Grid
item
xs={12}
md={12}
style={{
height: isMobile ? '550px' : '600px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
{/* AC-DC */}
<TopologyColumn
centerBox={{
title: 'AC-DC',
data: inv
? [{ value: acDcPower, unit: 'W' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: inv ? { value: acDcPower, unit: 'W' } : undefined,
amount: inv
? getAmount(highestConnectionValue, acDcPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={true}
/>
{/* DC Link */}
<TopologyColumn
centerBox={{
title: 'DC Link',
data: dcdc
? [{ value: dcLinkVoltage, unit: 'V' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: dcdc
? { value: dcLinkPower, unit: 'W' }
: undefined,
amount: dcdc
? getAmount(highestConnectionValue, dcLinkPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={false}
/>
{/* DC-DC */}
<TopologyColumn
centerBox={{
title: 'DC-DC',
data: dcdc
? [{ value: dcdcBatteryVoltage, unit: 'V' }]
: undefined,
connected: true
}}
centerConnection={{
orientation: 'horizontal',
data: dcdc
? { value: dcdcBatteryPower, unit: 'W' }
: undefined,
amount: dcdc
? getAmount(highestConnectionValue, dcdcBatteryPower)
: 0,
showValues: showValues
}}
isLast={false}
isFirst={false}
/>
{/* Battery clusters — one column per cluster, no chained lines */}
{clusterIds.map((id) => {
const c = clusters[id];
return (
<TopologyColumn
key={`battery-cluster-${id}`}
centerBox={{
title: `Battery ${id}`,
data: c
? [
{ value: Number(c.Soc ?? 0), unit: '%' },
{ value: Number(c.Voltage ?? 0), unit: 'V' },
{ value: Number(c.Current ?? 0), unit: 'A' },
{
value: Number(c.TemperatureCell1 ?? 0),
unit: '°C'
}
]
: undefined,
connected: true
}}
isLast={true}
isFirst={false}
/>
);
})}
</Grid>
</>
)}
</Grid>
</Container>
);
}
export default TopologySodistoreGrid;

View File

@ -67,7 +67,7 @@ function TopologyBox(props: TopologyBoxProps) {
width: isMobile ? '90px' : '104px', width: isMobile ? '90px' : '104px',
height: height:
props.title === 'Battery' props.title === 'Battery' || (props.title && props.title.startsWith('Battery '))
? '165px' ? '165px'
: props.title === 'AC Loads' || : props.title === 'AC Loads' ||
props.title === 'DC Loads' || props.title === 'DC Loads' ||

View File

@ -112,18 +112,23 @@ export const transformInputToBatteryViewDataJson = async (
'.Dc.Current' '.Dc.Current'
]; ];
const pathsToSearch = [ // SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
'Battery.Devices.1', // Built dynamically below from the first JSON chunk; other products use the fixed list.
'Battery.Devices.2', const pathsToSearch: string[] =
'Battery.Devices.3', product === 4
'Battery.Devices.4', ? []
'Battery.Devices.5', : [
'Battery.Devices.6', 'Battery.Devices.1',
'Battery.Devices.7', 'Battery.Devices.2',
'Battery.Devices.8', 'Battery.Devices.3',
'Battery.Devices.9', 'Battery.Devices.4',
'Battery.Devices.10' 'Battery.Devices.5',
]; 'Battery.Devices.6',
'Battery.Devices.7',
'Battery.Devices.8',
'Battery.Devices.9',
'Battery.Devices.10'
];
const pathsToSave = []; const pathsToSave = [];
@ -163,7 +168,6 @@ export const transformInputToBatteryViewDataJson = async (
//navigate(routes.login); //navigate(routes.login);
} }
}); });
for (var i = 0; i < timestampArray.length; i++) { for (var i = 0; i < timestampArray.length; i++) {
timestampPromises.push( timestampPromises.push(
fetchJsonDataForOneTime( fetchJsonDataForOneTime(
@ -311,21 +315,44 @@ export const transformInputToBatteryViewDataJson = async (
}); });
}); });
} else { } else {
// SaliMax, Salidomo, SodistoreMax: existing logic // SaliMax, Salidomo, SodistoreMax, SodistoreGrid: existing logic
const battery_nodes = // SodistoreGrid (product 4) batteries live under ListOfBatteriesRecord[cluster].Devices[id];
result.Config.Devices.BatteryNodes.toString().split(','); // enumerate them dynamically and build pathsToSearch in parallel with pathsToSave.
//Initialize the chartData structure based on the node names extracted from the first result
let old_length = pathsToSave.length; let old_length = pathsToSave.length;
if (battery_nodes.length > old_length) { if (product === 4) {
battery_nodes.forEach((node) => { const lobr = (result as any)?.ListOfBatteriesRecord ?? {};
const node_number = const clusters = Object.keys(lobr).sort(
product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1; (a, b) => Number(a) - Number(b)
if (!pathsToSave.includes('Node' + node_number)) { );
pathsToSave.push('Node' + node_number); clusters.forEach((clusterId) => {
} const devices = Object.keys(lobr[clusterId]?.Devices ?? {}).sort(
(a, b) => Number(a) - Number(b)
);
devices.forEach((deviceId) => {
const nodeName = `Node${clusterId}-${deviceId}`;
if (!pathsToSave.includes(nodeName)) {
pathsToSave.push(nodeName);
pathsToSearch.push(
`ListOfBatteriesRecord.${clusterId}.Devices.${deviceId}`
);
}
});
}); });
} else {
const battery_nodes =
result.Config.Devices.BatteryNodes.toString().split(',');
//Initialize the chartData structure based on the node names extracted from the first result
if (battery_nodes.length > old_length) {
battery_nodes.forEach((node) => {
const node_number =
product == 3 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number);
}
});
}
} }
if (initialiation) { if (initialiation) {
@ -341,7 +368,7 @@ export const transformInputToBatteryViewDataJson = async (
}); });
} }
if (battery_nodes.length > old_length) { if (pathsToSave.length > old_length) {
categories.forEach((category) => { categories.forEach((category) => {
pathsToSave.forEach((path) => { pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) { if (pathsToSave.indexOf(path) >= old_length) {
@ -454,6 +481,23 @@ export const transformInputToDailyDataJson = async (
null, // DCLoad not available for SodioHome null, // DCLoad not available for SodioHome
'SODIOHOME_BATTERY_VOLTAGE' 'SODIOHOME_BATTERY_VOLTAGE'
] ]
: product == 4
? [
// SodistoreGrid: placeholders — actual extraction runs in the
// product===4 switch below; nulls only mark "no data path", so
// the forEach skips the irrelevant categories. Battery voltage
// (index 8) MUST be a non-null placeholder, otherwise the entry
// is skipped before the switch can populate it.
'ListOfBatteriesRecord', // 0 soc
'ListOfBatteriesRecord', // 1 temperature
'ListOfBatteriesRecord', // 2 dcPower (battery power)
null, // 3 gridPower — no grid meter
null, // 4 pvProduction — no PV
'DcDc.Dc.Link.Voltage', // 5 dcBusVoltage
null, // 6 ACLoad — no AC load meter
null, // 7 DCLoad — no DC load meter
'ListOfBatteriesRecord' // 8 batteryVoltage (cluster avg)
]
: [ : [
'Battery.Soc', 'Battery.Soc',
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1', product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
@ -599,6 +643,46 @@ export const transformInputToDailyDataJson = async (
break; break;
} }
} }
} else if (product === 4) {
// SodiStoreGrid: only battery + DC-side metrics exist.
// No grid meter, no PV, no AC/DC load on this product — those series stay empty.
const lobr: Record<string, any> =
(result as any)?.ListOfBatteriesRecord ?? {};
const clusterValues = Object.values(lobr) as any[];
const avgOf = (pick: (c: any) => number | undefined): number | undefined => {
const xs = clusterValues
.map(pick)
.filter((v): v is number => typeof v === 'number');
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined;
};
const sumOf = (pick: (c: any) => number | undefined): number | undefined => {
const xs = clusterValues
.map(pick)
.filter((v): v is number => typeof v === 'number');
return xs.length ? xs.reduce((a, b) => a + b, 0) : undefined;
};
switch (category_index) {
case 0: // soc
value = avgOf((c) => c?.Soc);
break;
case 1: // temperature
value = avgOf((c) => c?.TemperatureCell1);
break;
case 2: // battery power (sum of cluster powers, else DcDc battery-side)
value =
sumOf((c) => c?.Power) ??
(result as any)?.DcDc?.Dc?.Battery?.Power;
break;
case 5: // dc bus voltage
value = (result as any)?.DcDc?.Dc?.Link?.Voltage;
break;
case 8: // battery voltage (cluster average, else DcDc battery side)
value =
avgOf((c) => c?.Voltage) ??
(result as any)?.DcDc?.Dc?.Battery?.Voltage;
break;
// case 3 (grid), 4 (PV), 6 (AC load), 7 (DC load) — not available on SodiStoreGrid
}
} else if (category_index === 4) { } else if (category_index === 4) {
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power' // Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
if (get(result, path) !== undefined) { if (get(result, path) !== undefined) {
@ -651,6 +735,15 @@ export const transformInputToDailyDataJson = async (
chartOverview[category].magnitude = magnitude; chartOverview[category].magnitude = magnitude;
}); });
// SodistoreGrid: 18 parallel battery devices easily swing into the kW range.
// Pin Battery Power to at least kW so the axis stays stable across day/night cycles.
if (product === 4) {
chartOverview.dcPower.magnitude = Math.max(
chartOverview.dcPower.magnitude,
1
);
}
chartOverview.soc.unit = '(%)'; chartOverview.soc.unit = '(%)';
chartOverview.soc.min = 0; chartOverview.soc.min = 0;
chartOverview.soc.max = 100; chartOverview.soc.max = 100;

View File

@ -23,7 +23,7 @@ export type ChecklistItem = {
updatedAt: string; updatedAt: string;
}; };
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]); export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 5]);
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([ export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
'checklistStep8Sub1', 'checklistStep8Sub1',