current price chart
This commit is contained in:
parent
879d848ed9
commit
4afeceea5d
|
|
@ -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<CurrentPriceSeries | null>(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 = (
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="currentPriceHistory"
|
||||||
|
defaultMessage="Current Price (last 7 days)"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{title}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: 280
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={36} sx={{ color: '#ffc04d' }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!series || series.data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{title}
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="currentPriceNoData"
|
||||||
|
defaultMessage="No price history available for the last 7 days."
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{title}
|
||||||
|
<ReactApexChart
|
||||||
|
options={options}
|
||||||
|
series={chartSeries}
|
||||||
|
type="line"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrentPriceChart;
|
||||||
|
|
@ -605,6 +605,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
values={values}
|
values={values}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
installation={props.current_installation}
|
installation={props.current_installation}
|
||||||
|
s3Credentials={s3Credentials}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,14 @@ import { DateTimePicker, TimePicker } from '@mui/x-date-pickers';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import { I_S3Credentials } from 'src/interfaces/S3Types';
|
||||||
|
import CurrentPriceChart from './CurrentPriceChart';
|
||||||
|
|
||||||
interface SodistoreHomeConfigurationProps {
|
interface SodistoreHomeConfigurationProps {
|
||||||
values: JSONRecordData;
|
values: JSONRecordData;
|
||||||
id: number;
|
id: number;
|
||||||
installation: I_Installation;
|
installation: I_Installation;
|
||||||
|
s3Credentials: I_S3Credentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
|
|
@ -961,6 +964,13 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px', marginTop: '10px' }}>
|
||||||
|
<CurrentPriceChart
|
||||||
|
s3Credentials={props.s3Credentials}
|
||||||
|
id={props.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'priceToSell' })}
|
label={intl.formatMessage({ id: 'priceToSell' })}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
import { I_S3Credentials } from 'src/interfaces/S3Types';
|
||||||
|
import { UnixTime } from 'src/dataCache/time';
|
||||||
|
import { FetchResult } from 'src/dataCache/dataCache';
|
||||||
|
import { fetchDataJson } from 'src/content/dashboards/Installations/fetchData';
|
||||||
|
|
||||||
|
// History of the `/Config/CurrentPrice` value (CHF/kWh) for the Configuration tab chart.
|
||||||
|
// Reuses the same plumbing as the battery charts: the backend
|
||||||
|
// (GetCsvTimestampsForInstallation) already downsamples to ~100 timestamps for any
|
||||||
|
// range, so a week-long fetch stays at ~100 S3 chunk reads.
|
||||||
|
|
||||||
|
export interface CurrentPriceSeries {
|
||||||
|
// [timestampMs, price] — timestampMs is local-time-shifted like the other charts
|
||||||
|
data: [number, number][];
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: CurrentPriceSeries = { data: [], min: 0, max: 0 };
|
||||||
|
|
||||||
|
// Fetch one chunk, retrying the next second (chunks land on even/odd seconds).
|
||||||
|
const fetchChunk = async (
|
||||||
|
ticks: number,
|
||||||
|
s3Credentials: I_S3Credentials
|
||||||
|
): Promise<Record<string, any> | 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<string, any>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and try next second / next timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPrice = (chunk: Record<string, any>): 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<CurrentPriceSeries> => {
|
||||||
|
let timestamps: number[] = [];
|
||||||
|
try {
|
||||||
|
const res: AxiosResponse<number[]> = 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 };
|
||||||
|
};
|
||||||
|
|
@ -590,6 +590,8 @@
|
||||||
"dynamicPricingSpotPrice": "Spot-Preis",
|
"dynamicPricingSpotPrice": "Spot-Preis",
|
||||||
"dynamicPricingTou": "TOU",
|
"dynamicPricingTou": "TOU",
|
||||||
"currentPrice": "Aktueller Preis",
|
"currentPrice": "Aktueller Preis",
|
||||||
|
"currentPriceHistory": "Aktueller Preis (letzte 7 Tage)",
|
||||||
|
"currentPriceNoData": "Keine Preishistorie für die letzten 7 Tage verfügbar.",
|
||||||
"priceToSell": "Verkaufspreis",
|
"priceToSell": "Verkaufspreis",
|
||||||
"priceToBuy": "Kaufpreis",
|
"priceToBuy": "Kaufpreis",
|
||||||
"timeToSell": "Verkaufszeit",
|
"timeToSell": "Verkaufszeit",
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,8 @@
|
||||||
"dynamicPricingSpotPrice": "Spot Price",
|
"dynamicPricingSpotPrice": "Spot Price",
|
||||||
"dynamicPricingTou": "TOU",
|
"dynamicPricingTou": "TOU",
|
||||||
"currentPrice": "Current Price",
|
"currentPrice": "Current Price",
|
||||||
|
"currentPriceHistory": "Current Price (last 7 days)",
|
||||||
|
"currentPriceNoData": "No price history available for the last 7 days.",
|
||||||
"priceToSell": "Price to Sell",
|
"priceToSell": "Price to Sell",
|
||||||
"priceToBuy": "Price to Buy",
|
"priceToBuy": "Price to Buy",
|
||||||
"timeToSell": "Time to Sell",
|
"timeToSell": "Time to Sell",
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,8 @@
|
||||||
"dynamicPricingSpotPrice": "Prix spot",
|
"dynamicPricingSpotPrice": "Prix spot",
|
||||||
"dynamicPricingTou": "TOU",
|
"dynamicPricingTou": "TOU",
|
||||||
"currentPrice": "Prix actuel",
|
"currentPrice": "Prix actuel",
|
||||||
|
"currentPriceHistory": "Prix actuel (7 derniers jours)",
|
||||||
|
"currentPriceNoData": "Aucun historique de prix disponible pour les 7 derniers jours.",
|
||||||
"priceToSell": "Prix de vente",
|
"priceToSell": "Prix de vente",
|
||||||
"priceToBuy": "Prix d'achat",
|
"priceToBuy": "Prix d'achat",
|
||||||
"timeToSell": "Heure de vente",
|
"timeToSell": "Heure de vente",
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,8 @@
|
||||||
"dynamicPricingSpotPrice": "Prezzo spot",
|
"dynamicPricingSpotPrice": "Prezzo spot",
|
||||||
"dynamicPricingTou": "TOU",
|
"dynamicPricingTou": "TOU",
|
||||||
"currentPrice": "Prezzo attuale",
|
"currentPrice": "Prezzo attuale",
|
||||||
|
"currentPriceHistory": "Prezzo attuale (ultimi 7 giorni)",
|
||||||
|
"currentPriceNoData": "Nessuno storico prezzi disponibile per gli ultimi 7 giorni.",
|
||||||
"priceToSell": "Prezzo di vendita",
|
"priceToSell": "Prezzo di vendita",
|
||||||
"priceToBuy": "Prezzo di acquisto",
|
"priceToBuy": "Prezzo di acquisto",
|
||||||
"timeToSell": "Orario di vendita",
|
"timeToSell": "Orario di vendita",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue