sodistore grid
This commit is contained in:
parent
795e77d304
commit
74eaa258e1
|
|
@ -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' }}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ?? '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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',
|
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' ||
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue