sodistore grid

This commit is contained in:
Yinyin Liu 2026-05-18 17:14:14 +02:00
parent 795e77d304
commit 74eaa258e1
10 changed files with 668 additions and 136 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

@ -25,7 +25,7 @@ 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 PvView from '../PvView/PvView'; import PvView from '../PvView/PvView';
@ -51,6 +51,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 +83,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 +175,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 +296,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 +380,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 +430,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 +465,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 +574,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}

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

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