From 4afeceea5d26e24ba8297516bf5b9a82827538f8 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 11 Jun 2026 16:32:49 +0200 Subject: [PATCH] current price chart --- .../CurrentPriceChart.tsx | 152 ++++++++++++++++++ .../SodiohomeInstallations/Installation.tsx | 1 + .../SodistoreHomeConfigurationV2.tsx | 10 ++ .../currentPriceData.ts | 103 ++++++++++++ typescript/frontend-marios2/src/lang/de.json | 2 + typescript/frontend-marios2/src/lang/en.json | 2 + typescript/frontend-marios2/src/lang/fr.json | 2 + typescript/frontend-marios2/src/lang/it.json | 2 + 8 files changed, 274 insertions(+) create mode 100644 typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/CurrentPriceChart.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/currentPriceData.ts diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/CurrentPriceChart.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/CurrentPriceChart.tsx new file mode 100644 index 000000000..bebdb4b24 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/CurrentPriceChart.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Typography } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import { FormattedMessage, useIntl } from 'react-intl'; +import ReactApexChart from 'react-apexcharts'; +import { ApexOptions } from 'apexcharts'; +import { I_S3Credentials } from 'src/interfaces/S3Types'; +import { UnixTime, TimeSpan } from 'src/dataCache/time'; +import { + CurrentPriceSeries, + fetchCurrentPriceHistory +} from './currentPriceData'; + +interface CurrentPriceChartProps { + s3Credentials: I_S3Credentials; + id: number; +} + +const HISTORY_DAYS = 7; + +function CurrentPriceChart(props: CurrentPriceChartProps) { + const intl = useIntl(); + const [series, setSeries] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + const end = UnixTime.now(); + const start = end.earlier(TimeSpan.fromDays(HISTORY_DAYS)); + + fetchCurrentPriceHistory(props.s3Credentials, props.id, start, end) + .then((result) => { + if (!cancelled) { + setSeries(result); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setSeries({ data: [], min: 0, max: 0 }); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [props.id]); + + const title = ( + + + + ); + + if (loading) { + return ( + + {title} + + + + + ); + } + + if (!series || series.data.length === 0) { + return ( + + {title} + + + + + ); + } + + const options: ApexOptions = { + chart: { + id: 'current-price-history', + type: 'line', + height: 300, + toolbar: { show: false }, + zoom: { autoScaleYaxis: true } + }, + dataLabels: { enabled: false }, + // Spot price is constant within each pricing interval -> stepped line + stroke: { curve: 'stepline', width: 2 }, + colors: ['#5569ff'], + xaxis: { + type: 'datetime', + labels: { + datetimeFormatter: { + year: 'yyyy', + month: "MMM 'yy", + day: 'dd MMM', + hour: 'HH:mm' + } + } + }, + yaxis: { + min: 0, + tickAmount: 5, + title: { text: 'CHF/kWh', style: { fontSize: '12px' } }, + labels: { formatter: (v: number) => (v == null ? '' : v.toFixed(3)) } + }, + tooltip: { + x: { format: 'dd MMM HH:mm' }, + y: { + formatter: (v: number) => + v == null || Number.isNaN(v) ? '-' : v.toFixed(3) + ' CHF/kWh' + } + }, + grid: { padding: { top: 10 } } + }; + + const chartSeries = [ + { + name: intl.formatMessage({ id: 'currentPrice' }), + data: series.data + } + ]; + + return ( + + {title} + + + ); +} + +export default CurrentPriceChart; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 73705dfcd..e5f06decc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -605,6 +605,7 @@ function SodioHomeInstallation(props: singleInstallationProps) { values={values} id={props.current_installation.id} installation={props.current_installation} + s3Credentials={s3Credentials} /> } /> diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfigurationV2.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfigurationV2.tsx index fa290b479..08bd76078 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfigurationV2.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfigurationV2.tsx @@ -43,11 +43,14 @@ import { DateTimePicker, TimePicker } from '@mui/x-date-pickers'; import dayjs from 'dayjs'; import Switch from '@mui/material/Switch'; import FormControlLabel from '@mui/material/FormControlLabel'; +import { I_S3Credentials } from 'src/interfaces/S3Types'; +import CurrentPriceChart from './CurrentPriceChart'; interface SodistoreHomeConfigurationProps { values: JSONRecordData; id: number; installation: I_Installation; + s3Credentials: I_S3Credentials; } function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) { @@ -961,6 +964,13 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) { /> +
+ +
+
| null> => { + for (let i = 0; i < 2; i++) { + try { + const res = await fetchDataJson( + UnixTime.fromTicks(ticks + i, true), + s3Credentials + ); + if ( + res && + res !== FetchResult.notAvailable && + res !== FetchResult.tryLater + ) { + return res as Record; + } + } catch { + // ignore and try next second / next timestamp + } + } + return null; +}; + +const extractPrice = (chunk: Record): number | null => { + const keys = Object.keys(chunk); + if (keys.length === 0) return null; + const record = chunk[keys[keys.length - 1]]; + const price = record?.Config?.CurrentPrice; + if (price === undefined || price === null || Number.isNaN(Number(price))) { + return null; + } + return Number(price); +}; + +// Growatt (device 3 / product 2) and Sinexcel (device 4 / product 5) both store +// 10-digit second timestamps, so the chart axis uses `ticks * 1000`, shifted into +// the browser's local zone to match the rest of the dashboards. +const toLocalMs = (ticks: number): number => { + const d = new Date(ticks * 1000); + d.setHours(d.getHours() - d.getTimezoneOffset() / 60); + return d.getTime(); +}; + +export const fetchCurrentPriceHistory = async ( + s3Credentials: I_S3Credentials, + id: number, + start: UnixTime, + end: UnixTime +): Promise => { + let timestamps: number[] = []; + try { + const res: AxiosResponse = await axiosConfig.get( + `/GetCsvTimestampsForInstallation?id=${id}&start=${start.ticks}&end=${end.ticks}` + ); + timestamps = res.data ?? []; + } catch { + return EMPTY; + } + + const chunks = await Promise.all( + timestamps.map((t) => fetchChunk(t, s3Credentials)) + ); + + const data: [number, number][] = []; + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + chunks.forEach((chunk, i) => { + if (!chunk) return; + const price = extractPrice(chunk); + if (price === null) return; + data.push([toLocalMs(timestamps[i]), price]); + if (price < min) min = price; + if (price > max) max = price; + }); + + if (data.length === 0) return EMPTY; + data.sort((a, b) => a[0] - b[0]); + return { data, min, max }; +}; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index b5f93aa47..8bd6a33fe 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -590,6 +590,8 @@ "dynamicPricingSpotPrice": "Spot-Preis", "dynamicPricingTou": "TOU", "currentPrice": "Aktueller Preis", + "currentPriceHistory": "Aktueller Preis (letzte 7 Tage)", + "currentPriceNoData": "Keine Preishistorie für die letzten 7 Tage verfügbar.", "priceToSell": "Verkaufspreis", "priceToBuy": "Kaufpreis", "timeToSell": "Verkaufszeit", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 41edece41..eebb29fba 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -338,6 +338,8 @@ "dynamicPricingSpotPrice": "Spot Price", "dynamicPricingTou": "TOU", "currentPrice": "Current Price", + "currentPriceHistory": "Current Price (last 7 days)", + "currentPriceNoData": "No price history available for the last 7 days.", "priceToSell": "Price to Sell", "priceToBuy": "Price to Buy", "timeToSell": "Time to Sell", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index e871aa54b..20790a221 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -590,6 +590,8 @@ "dynamicPricingSpotPrice": "Prix spot", "dynamicPricingTou": "TOU", "currentPrice": "Prix actuel", + "currentPriceHistory": "Prix actuel (7 derniers jours)", + "currentPriceNoData": "Aucun historique de prix disponible pour les 7 derniers jours.", "priceToSell": "Prix de vente", "priceToBuy": "Prix d'achat", "timeToSell": "Heure de vente", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index f505c03d3..d742ae504 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -590,6 +590,8 @@ "dynamicPricingSpotPrice": "Prezzo spot", "dynamicPricingTou": "TOU", "currentPrice": "Prezzo attuale", + "currentPriceHistory": "Prezzo attuale (ultimi 7 giorni)", + "currentPriceNoData": "Nessuno storico prezzi disponibile per gli ultimi 7 giorni.", "priceToSell": "Prezzo di vendita", "priceToBuy": "Prezzo di acquisto", "timeToSell": "Orario di vendita",