Merge branch 'feature/sodistoregrid-live-view' into main
This commit is contained in:
commit
8b8fe8cf2e
|
|
@ -45,14 +45,36 @@ function BatteryView(props: BatteryViewProps) {
|
|||
const navigate = useNavigate();
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
|
||||
const sortedBatteryView =
|
||||
props.values != null && props.values?.Battery?.Devices
|
||||
? Object.entries(props.values.Battery.Devices)
|
||||
.map(([BatteryId, battery]) => {
|
||||
return { BatteryId, battery }; // Here we return an object with the id and device
|
||||
})
|
||||
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId))
|
||||
: [];
|
||||
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
|
||||
// Flatten into a single list with composite IDs "{cluster}-{device}" so the existing
|
||||
// BatteryView table renders without further changes.
|
||||
const sortedBatteryView = (() => {
|
||||
if (props.values == null) return [];
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -177,39 +199,37 @@ function BatteryView(props: BatteryViewProps) {
|
|||
}
|
||||
/>
|
||||
{product === 0
|
||||
? Object.entries(props.values.Battery.Devices).map(
|
||||
([BatteryId, battery]) => (
|
||||
<Route
|
||||
key={routes.detailed_view + BatteryId}
|
||||
path={routes.detailed_view + BatteryId}
|
||||
element={
|
||||
<DetailedBatteryView
|
||||
batteryId={Number(BatteryId)}
|
||||
s3Credentials={props.s3Credentials}
|
||||
batteryData={battery}
|
||||
installationId={props.installationId}
|
||||
productNum={product}
|
||||
></DetailedBatteryView>
|
||||
}
|
||||
/>
|
||||
)
|
||||
? sortedBatteryView.map(({ BatteryId, battery }) => (
|
||||
<Route
|
||||
key={routes.detailed_view + BatteryId}
|
||||
path={routes.detailed_view + BatteryId}
|
||||
element={
|
||||
<DetailedBatteryView
|
||||
batteryId={Number(BatteryId)}
|
||||
s3Credentials={props.s3Credentials}
|
||||
batteryData={battery}
|
||||
installationId={props.installationId}
|
||||
productNum={product}
|
||||
></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>
|
||||
</Grid>
|
||||
|
|
@ -262,7 +282,7 @@ function BatteryView(props: BatteryViewProps) {
|
|||
component="th"
|
||||
scope="row"
|
||||
align="center"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }}
|
||||
>
|
||||
<Link
|
||||
style={{ color: 'black' }}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|||
import GaugeChart from 'react-gauge-chart';
|
||||
|
||||
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;
|
||||
batteryData: Device;
|
||||
installationId: number;
|
||||
|
|
|
|||
|
|
@ -134,34 +134,22 @@ function MainStats(props: MainStatsProps) {
|
|||
|
||||
function generateSeries(chartData, category, color) {
|
||||
const series = [];
|
||||
const pathsToSearch = [
|
||||
'Node0',
|
||||
'Node1',
|
||||
'Node2',
|
||||
'Node3',
|
||||
'Node4',
|
||||
'Node5',
|
||||
'Node6',
|
||||
'Node7',
|
||||
'Node8',
|
||||
'Node9',
|
||||
'Node10'
|
||||
];
|
||||
// Use all actually-present series keys so product 4 (SodiStoreGrid)
|
||||
// composite names like "Node1-1".."Node3-6" are picked up too.
|
||||
const presentPaths = Object.keys(chartData[category]?.data ?? {}).sort();
|
||||
|
||||
let i = 0;
|
||||
pathsToSearch.forEach((devicePath) => {
|
||||
if (
|
||||
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
|
||||
chartData[category].data[devicePath].data.length != 0
|
||||
) {
|
||||
presentPaths.forEach((devicePath) => {
|
||||
if (chartData[category].data[devicePath]?.data?.length) {
|
||||
const palette =
|
||||
color === 'blue'
|
||||
? blueColors
|
||||
: color === 'red'
|
||||
? redColors
|
||||
: orangeColors;
|
||||
series.push({
|
||||
...chartData[category].data[devicePath],
|
||||
color:
|
||||
color === 'blue'
|
||||
? blueColors[i]
|
||||
: color === 'red'
|
||||
? redColors[i]
|
||||
: orangeColors[i]
|
||||
color: palette[i % palette.length]
|
||||
});
|
||||
}
|
||||
i++;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -25,9 +25,10 @@ import Information from '../Information/Information';
|
|||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import HistoryOfActions from '../History/History';
|
||||
import Topology from '../Topology/Topology';
|
||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||
import TopologySodistoreGrid from '../Topology/TopologySodistoreGrid';
|
||||
import BatteryView from '../BatteryView/BatteryView';
|
||||
import Configuration from '../Configuration/Configuration';
|
||||
import ConfigurationSodistoreGrid from '../Configuration/ConfigurationSodistoreGrid';
|
||||
import PvView from '../PvView/PvView';
|
||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||
import DocumentsTab from '../Documents/DocumentsTab';
|
||||
|
|
@ -51,6 +52,9 @@ function Installation(props: singleInstallationProps) {
|
|||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
const [values, setValues] = useState<JSONRecordData | null>(null);
|
||||
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 [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)
|
||||
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 () => {
|
||||
var timeperiodToSearch = 70;
|
||||
let res;
|
||||
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) {
|
||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||
try {
|
||||
|
|
@ -122,6 +176,55 @@ function Installation(props: singleInstallationProps) {
|
|||
let res;
|
||||
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) {
|
||||
if (!continueFetching.current) {
|
||||
return false;
|
||||
|
|
@ -194,12 +297,14 @@ function Installation(props: singleInstallationProps) {
|
|||
setCurrentTab(path[path.length - 1]);
|
||||
}, [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(() => {
|
||||
if (status === -1) {
|
||||
if (status === -1 && props.current_installation.product !== 4) {
|
||||
setConnected(false);
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, props.current_installation.product]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -276,7 +381,7 @@ function Installation(props: singleInstallationProps) {
|
|||
</Typography>
|
||||
</div>
|
||||
|
||||
{currentTab == 'live' && values && (
|
||||
{currentTab == 'live' && values && values.EssControl?.Mode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
fontWeight="bold"
|
||||
|
|
@ -326,7 +431,7 @@ function Installation(props: singleInstallationProps) {
|
|||
marginTop: '-10px'
|
||||
}}
|
||||
>
|
||||
{status === -1 ? (
|
||||
{status === -1 && !(props.current_installation.product === 4 && connected) ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
|
|
@ -361,7 +466,8 @@ function Installation(props: singleInstallationProps) {
|
|||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
: (status === -1 || status === -2) &&
|
||||
!(props.current_installation.product === 4 && connected)
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
|
|
@ -469,12 +575,11 @@ function Installation(props: singleInstallationProps) {
|
|||
path={routes.live}
|
||||
element={
|
||||
props.current_installation.product === 4 ? (
|
||||
<TopologySodistoreHome
|
||||
<TopologySodistoreGrid
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
></TopologySodistoreGrid>
|
||||
) : (
|
||||
<Topology
|
||||
values={values}
|
||||
|
|
@ -501,20 +606,10 @@ function Installation(props: singleInstallationProps) {
|
|||
path={routes.configuration}
|
||||
element={
|
||||
props.current_installation.product === 4 ? (
|
||||
// TODO: SodistoreGrid — implement actual configuration
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Configuration not yet available
|
||||
</Typography>
|
||||
</Container>
|
||||
<ConfigurationSodistoreGrid
|
||||
values={values}
|
||||
id={props.current_installation.id}
|
||||
/>
|
||||
) : (
|
||||
<Configuration
|
||||
values={values}
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
|
||||
const hidePvView = props.product === 4;
|
||||
|
||||
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
|
||||
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
|
||||
const showChecklist = props.product === 4;
|
||||
// Checklist is not shown for any product in the Installations view.
|
||||
// Salimax (0) / Salidomo (1) / SodistoreMax (3) / SodistoreGrid (4) use different onboarding flows.
|
||||
const showChecklist = false;
|
||||
|
||||
const singleInstallationTabs = (
|
||||
currentUser.userType == UserType.admin
|
||||
|
|
|
|||
|
|
@ -594,6 +594,19 @@ export interface JSONRecordData {
|
|||
// [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 = (
|
||||
|
|
@ -1174,7 +1187,9 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
|
|||
'InverterRecord.TotalBatteryPower',
|
||||
'InverterRecord.TotalPhotovoltaicPower',
|
||||
'InverterRecord.TotalLoadPower',
|
||||
'InverterRecord.TotalGridPower'
|
||||
'InverterRecord.TotalGridPower',
|
||||
'InverterRecord.ActivePowerW',
|
||||
'DcDc.Dc.Battery.Power'
|
||||
];
|
||||
|
||||
// Helper function to safely get a value from a nested path
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ export const getChartOptions = (
|
|||
type: string,
|
||||
dateList: string[],
|
||||
stacked: Boolean,
|
||||
voltageInfo?: chartInfoInterface
|
||||
voltageInfo?: chartInfoInterface,
|
||||
powerLabel?: string,
|
||||
temperatureInfo?: chartInfoInterface
|
||||
): ApexOptions => {
|
||||
return type.includes('daily')
|
||||
? {
|
||||
|
|
@ -57,7 +59,7 @@ export const getChartOptions = (
|
|||
type === 'dailyoverview'
|
||||
? [
|
||||
{
|
||||
seriesName: 'Grid Power',
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
|
|
@ -94,7 +96,7 @@ export const getChartOptions = (
|
|||
}
|
||||
},
|
||||
{
|
||||
seriesName: 'Grid Power',
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
|
|
@ -123,7 +125,7 @@ export const getChartOptions = (
|
|||
}
|
||||
},
|
||||
{
|
||||
seriesName: 'Grid Power',
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
|
|
@ -192,6 +194,27 @@ export const getChartOptions = (
|
|||
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: {
|
||||
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;
|
||||
if (seriesName === 'Battery SOC') {
|
||||
return val.toFixed(2) + ' %';
|
||||
} else if (seriesName === 'Battery Voltage') {
|
||||
return val.toFixed(2) + ' (V)';
|
||||
} else if (seriesName === 'Battery Temperature') {
|
||||
return val.toFixed(2) + ' (°C)';
|
||||
} else {
|
||||
const formatted = formatPowerForGraph(val, chartInfo.magnitude);
|
||||
const raw = formatted?.value;
|
||||
return (
|
||||
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed(
|
||||
2
|
||||
) +
|
||||
(raw === undefined || raw === null || Number.isNaN(raw)
|
||||
? '-'
|
||||
: raw.toFixed(2)) +
|
||||
' ' +
|
||||
chartInfo.unit
|
||||
(chartInfo.unit ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</Button>
|
||||
{props.device !== 3 && props.product !== 2 && (
|
||||
{props.device !== 3 && props.product !== 2 && props.product !== 4 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
|
|
@ -663,7 +663,7 @@ function Overview(props: OverviewProps) {
|
|||
</Container>
|
||||
)}
|
||||
|
||||
{!loading && dailyDataArray.length > 0 && (
|
||||
{!loading && ((dailyData && dailyDataArray.length > 0) || (aggregatedData && aggregatedDataArray.length > 0)) && (
|
||||
<Grid item xs={12} md={12}>
|
||||
{dailyData && (
|
||||
<Grid
|
||||
|
|
@ -709,11 +709,22 @@ function Overview(props: OverviewProps) {
|
|||
<ReactApexChart
|
||||
options={{
|
||||
...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',
|
||||
[],
|
||||
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: {
|
||||
events: {
|
||||
|
|
@ -725,32 +736,64 @@ function Overview(props: OverviewProps) {
|
|||
}
|
||||
}}
|
||||
series={[
|
||||
{
|
||||
...dailyDataArray[chartState].chartData.gridPower,
|
||||
type: 'line',
|
||||
color: '#b30000'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.pvProduction,
|
||||
type: 'line',
|
||||
color: '#ff9900'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData.ACLoad,
|
||||
type: 'line',
|
||||
color: '#2ecc71'
|
||||
},
|
||||
// SodiStoreGrid (product 4) has no grid meter, no PV, no AC load.
|
||||
...(product !== 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.gridPower,
|
||||
type: 'line' as const,
|
||||
color: '#b30000'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.pvProduction,
|
||||
type: 'line' as const,
|
||||
color: '#ff9900'
|
||||
},
|
||||
{
|
||||
...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,
|
||||
type: 'line',
|
||||
color: '#008FFB'
|
||||
},
|
||||
...((product === 2 || product === 5) ? [{
|
||||
...dailyDataArray[chartState].chartData.batteryVoltage,
|
||||
type: 'line' as const,
|
||||
color: '#9b59b6'
|
||||
}] : [])
|
||||
...(product === 2 || product === 5 || product === 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.batteryVoltage,
|
||||
type: 'line' as const,
|
||||
color: '#9b59b6'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(product === 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.temperature,
|
||||
type: 'line' as const,
|
||||
color: '#16a085'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
height={420}
|
||||
/>
|
||||
|
|
@ -1215,6 +1258,8 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
)}
|
||||
|
||||
{/* PV Production + Grid Power — hidden for SodiStoreGrid (no PV, no grid meter) */}
|
||||
{product !== 4 && (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
|
|
@ -1403,6 +1448,7 @@ function Overview(props: OverviewProps) {
|
|||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{aggregatedData && (product === 2 || product === 5) && (
|
||||
<Grid
|
||||
|
|
@ -1461,7 +1507,7 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
)}
|
||||
|
||||
{dailyData && (
|
||||
{dailyData && product !== 4 && (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -67,7 +67,7 @@ function TopologyBox(props: TopologyBoxProps) {
|
|||
|
||||
width: isMobile ? '90px' : '104px',
|
||||
height:
|
||||
props.title === 'Battery'
|
||||
props.title === 'Battery' || (props.title && props.title.startsWith('Battery '))
|
||||
? '165px'
|
||||
: props.title === 'AC Loads' ||
|
||||
props.title === 'DC Loads' ||
|
||||
|
|
|
|||
|
|
@ -112,18 +112,23 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
'.Dc.Current'
|
||||
];
|
||||
|
||||
const pathsToSearch = [
|
||||
'Battery.Devices.1',
|
||||
'Battery.Devices.2',
|
||||
'Battery.Devices.3',
|
||||
'Battery.Devices.4',
|
||||
'Battery.Devices.5',
|
||||
'Battery.Devices.6',
|
||||
'Battery.Devices.7',
|
||||
'Battery.Devices.8',
|
||||
'Battery.Devices.9',
|
||||
'Battery.Devices.10'
|
||||
];
|
||||
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
|
||||
// Built dynamically below from the first JSON chunk; other products use the fixed list.
|
||||
const pathsToSearch: string[] =
|
||||
product === 4
|
||||
? []
|
||||
: [
|
||||
'Battery.Devices.1',
|
||||
'Battery.Devices.2',
|
||||
'Battery.Devices.3',
|
||||
'Battery.Devices.4',
|
||||
'Battery.Devices.5',
|
||||
'Battery.Devices.6',
|
||||
'Battery.Devices.7',
|
||||
'Battery.Devices.8',
|
||||
'Battery.Devices.9',
|
||||
'Battery.Devices.10'
|
||||
];
|
||||
|
||||
const pathsToSave = [];
|
||||
|
||||
|
|
@ -163,7 +168,6 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
//navigate(routes.login);
|
||||
}
|
||||
});
|
||||
|
||||
for (var i = 0; i < timestampArray.length; i++) {
|
||||
timestampPromises.push(
|
||||
fetchJsonDataForOneTime(
|
||||
|
|
@ -311,21 +315,44 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
});
|
||||
});
|
||||
} else {
|
||||
// SaliMax, Salidomo, SodistoreMax: existing logic
|
||||
const battery_nodes =
|
||||
result.Config.Devices.BatteryNodes.toString().split(',');
|
||||
|
||||
//Initialize the chartData structure based on the node names extracted from the first result
|
||||
// SaliMax, Salidomo, SodistoreMax, SodistoreGrid: existing logic
|
||||
// SodistoreGrid (product 4) batteries live under ListOfBatteriesRecord[cluster].Devices[id];
|
||||
// enumerate them dynamically and build pathsToSearch in parallel with pathsToSave.
|
||||
let old_length = pathsToSave.length;
|
||||
|
||||
if (battery_nodes.length > old_length) {
|
||||
battery_nodes.forEach((node) => {
|
||||
const node_number =
|
||||
product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1;
|
||||
if (!pathsToSave.includes('Node' + node_number)) {
|
||||
pathsToSave.push('Node' + node_number);
|
||||
}
|
||||
if (product === 4) {
|
||||
const lobr = (result as any)?.ListOfBatteriesRecord ?? {};
|
||||
const clusters = Object.keys(lobr).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
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) {
|
||||
|
|
@ -341,7 +368,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
});
|
||||
}
|
||||
|
||||
if (battery_nodes.length > old_length) {
|
||||
if (pathsToSave.length > old_length) {
|
||||
categories.forEach((category) => {
|
||||
pathsToSave.forEach((path) => {
|
||||
if (pathsToSave.indexOf(path) >= old_length) {
|
||||
|
|
@ -454,6 +481,23 @@ export const transformInputToDailyDataJson = async (
|
|||
null, // DCLoad not available for SodioHome
|
||||
'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',
|
||||
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
|
||||
|
|
@ -599,6 +643,46 @@ export const transformInputToDailyDataJson = async (
|
|||
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) {
|
||||
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
|
||||
if (get(result, path) !== undefined) {
|
||||
|
|
@ -651,6 +735,15 @@ export const transformInputToDailyDataJson = async (
|
|||
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.min = 0;
|
||||
chartOverview.soc.max = 100;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type ChecklistItem = {
|
|||
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([
|
||||
'checklistStep8Sub1',
|
||||
|
|
|
|||
Loading…
Reference in New Issue