Compare commits
No commits in common. "b09cf00d781e45c10bf7165020817be490e08ef9" and "879d848ed9103809fa6481ccb9e4b7d4a77195fb" have entirely different histories.
b09cf00d78
...
879d848ed9
|
|
@ -415,34 +415,6 @@ public class Controller : ControllerBase
|
||||||
return sampledTimestamps;
|
return sampledTimestamps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic-pricing "Current Price" history (CHF/kWh) for the Configuration tab chart.
|
|
||||||
// Only Growatt (device 3) and Sinexcel (device 4) carry Config.CurrentPrice.
|
|
||||||
[HttpGet(nameof(GetCurrentPriceHistory))]
|
|
||||||
public async Task<ActionResult<IEnumerable<PricePoint>>> GetCurrentPriceHistory(Int64 id, Int64 start, Int64 end, Token authToken)
|
|
||||||
{
|
|
||||||
var user = Db.GetSession(authToken)?.User;
|
|
||||||
if (user == null)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var installation = Db.GetInstallationById(id);
|
|
||||||
if (installation is null || !user.HasAccessTo(installation))
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
if (installation.Device != 3 && installation.Device != 4)
|
|
||||||
return Ok(new List<PricePoint>());
|
|
||||||
|
|
||||||
// Clamp the range: each day spawns s3cmd processes + S3 fetches, so an
|
|
||||||
// unbounded range from a crafted request could exhaust the single prod backend.
|
|
||||||
const Int64 maxRangeSeconds = 31L * 86400;
|
|
||||||
if (start < 0 || end <= start)
|
|
||||||
return Ok(new List<PricePoint>());
|
|
||||||
if (end - start > maxRangeSeconds)
|
|
||||||
start = end - maxRangeSeconds;
|
|
||||||
|
|
||||||
var history = await CurrentPriceHistoryService.GetHistory(installation, start, end);
|
|
||||||
return Ok(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet(nameof(GetUserById))]
|
[HttpGet(nameof(GetUserById))]
|
||||||
public ActionResult<User> GetUserById(Int64 id, Token authToken)
|
public ActionResult<User> GetUserById(Int64 id, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
|
||||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
|
||||||
using InnovEnergy.Lib.S3Utils;
|
|
||||||
using S3Bucket = InnovEnergy.Lib.S3Utils.DataTypes.S3Bucket;
|
|
||||||
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
|
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One data point of the dynamic-pricing "Current Price" history (CHF/kWh).
|
|
||||||
/// </summary>
|
|
||||||
public class PricePoint
|
|
||||||
{
|
|
||||||
public Int64 Timestamp { get; set; } // unix seconds
|
|
||||||
public Double Price { get; set; } // CHF/kWh
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the "Current Price" history shown on the Configuration tab for
|
|
||||||
/// Growatt (device 3) and Sinexcel (device 4) installations.
|
|
||||||
///
|
|
||||||
/// CurrentPrice only lives inside the per-10-second chunk files (Config.CurrentPrice);
|
|
||||||
/// there is no pre-aggregated source. To keep load bounded we sample ONE chunk per
|
|
||||||
/// 15-minute slot, fetch those in parallel, and cache each fully-past day (immutable).
|
|
||||||
/// </summary>
|
|
||||||
public static class CurrentPriceHistoryService
|
|
||||||
{
|
|
||||||
private const Int64 BucketSeconds = 900; // 15-minute resolution
|
|
||||||
private const Int32 MaxParallelFetches = 24;
|
|
||||||
private const Int32 CacheRetentionDays = 14; // bound the in-memory day cache
|
|
||||||
private const String S3CfgPath = "/home/ubuntu/.s3cfg";
|
|
||||||
|
|
||||||
private static readonly Regex ChunkFileRegex = new(@"/([0-9]+)\.(csv|json)$", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
// Immutable past days cached as "{installationId}:{yyyyMMdd}".
|
|
||||||
private static readonly ConcurrentDictionary<String, List<PricePoint>> DayCache = new();
|
|
||||||
|
|
||||||
public static async Task<List<PricePoint>> GetHistory(Installation installation, Int64 startSec, Int64 endSec)
|
|
||||||
{
|
|
||||||
var todayUtc = DateTime.UtcNow.Date;
|
|
||||||
var firstDay = DateTimeOffset.FromUnixTimeSeconds(startSec).UtcDateTime.Date;
|
|
||||||
var lastDay = DateTimeOffset.FromUnixTimeSeconds(endSec).UtcDateTime.Date;
|
|
||||||
|
|
||||||
var points = new List<PricePoint>();
|
|
||||||
for (var day = firstDay; day <= lastDay; day = day.AddDays(1))
|
|
||||||
points.AddRange(await GetDay(installation, day, cacheable: day < todayUtc));
|
|
||||||
|
|
||||||
return points
|
|
||||||
.Where(p => p.Timestamp >= startSec && p.Timestamp <= endSec)
|
|
||||||
.OrderBy(p => p.Timestamp)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<PricePoint>> GetDay(Installation installation, DateTime dayUtc, Boolean cacheable)
|
|
||||||
{
|
|
||||||
var key = $"{installation.Id}:{dayUtc:yyyyMMdd}";
|
|
||||||
if (cacheable && DayCache.TryGetValue(key, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
var dayStart = new DateTimeOffset(dayUtc, TimeSpan.Zero).ToUnixTimeSeconds();
|
|
||||||
var dayEnd = dayStart + 86400 - 1;
|
|
||||||
|
|
||||||
var timestamps = SampleByBucket(ListChunkTimestamps(installation, dayStart, dayEnd));
|
|
||||||
var points = await FetchPrices(installation, timestamps);
|
|
||||||
|
|
||||||
// Only cache non-empty past days: an empty result can mean a transient s3cmd/S3
|
|
||||||
// failure, and caching that would serve "no data" forever until restart.
|
|
||||||
if (cacheable && points.Count > 0)
|
|
||||||
CacheDay(key, points);
|
|
||||||
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CacheDay(String key, List<PricePoint> points)
|
|
||||||
{
|
|
||||||
DayCache[key] = points;
|
|
||||||
|
|
||||||
// Prune entries older than the retention window to bound memory growth.
|
|
||||||
var cutoff = DateTime.UtcNow.Date.AddDays(-CacheRetentionDays);
|
|
||||||
foreach (var existingKey in DayCache.Keys)
|
|
||||||
{
|
|
||||||
var datePart = existingKey.Substring(existingKey.IndexOf(':') + 1);
|
|
||||||
if (DateTime.TryParseExact(datePart, "yyyyMMdd", CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.None, out var day) && day < cutoff)
|
|
||||||
DayCache.TryRemove(existingKey, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the first chunk in each 15-minute slot.
|
|
||||||
private static List<Int64> SampleByBucket(List<Int64> timestamps)
|
|
||||||
{
|
|
||||||
var seenBuckets = new HashSet<Int64>();
|
|
||||||
var picked = new List<Int64>();
|
|
||||||
foreach (var t in timestamps.OrderBy(x => x))
|
|
||||||
if (seenBuckets.Add(t / BucketSeconds))
|
|
||||||
picked.Add(t);
|
|
||||||
return picked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List every chunk filename in range via `s3cmd ls` over the 5-digit timestamp prefixes
|
|
||||||
// (same listing approach as Controller.GetCsvTimestampsForInstallation).
|
|
||||||
private static List<Int64> ListChunkTimestamps(Installation installation, Int64 start, Int64 end)
|
|
||||||
{
|
|
||||||
var all = new List<Int64>();
|
|
||||||
var startPrefix = Int64.Parse(start.ToString().Substring(0, 5));
|
|
||||||
var endPrefix = Int64.Parse(end.ToString().Substring(0, 5));
|
|
||||||
|
|
||||||
for (var prefix = startPrefix; prefix <= endPrefix; prefix++)
|
|
||||||
{
|
|
||||||
var output = RunS3cmdLs("s3://" + installation.BucketName() + "/" + prefix);
|
|
||||||
foreach (var line in output.Split('\n'))
|
|
||||||
{
|
|
||||||
var match = ChunkFileRegex.Match(line);
|
|
||||||
if (match.Success && Int64.TryParse(match.Groups[1].Value, out var t) && t >= start && t <= end)
|
|
||||||
all.Add(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String RunS3cmdLs(String bucketPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "s3cmd",
|
|
||||||
Arguments = $"--config {S3CfgPath} ls {bucketPath}",
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
using var process = new Process { StartInfo = startInfo };
|
|
||||||
process.Start();
|
|
||||||
var output = process.StandardOutput.ReadToEnd();
|
|
||||||
process.StandardError.ReadToEnd();
|
|
||||||
process.WaitForExit();
|
|
||||||
return process.ExitCode == 0 ? output : "";
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[CurrentPriceHistory] s3cmd ls failed for {bucketPath}: {e.Message}");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<PricePoint>> FetchPrices(Installation installation, List<Int64> timestamps)
|
|
||||||
{
|
|
||||||
var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!);
|
|
||||||
var bucket = region.Bucket(installation.BucketName());
|
|
||||||
|
|
||||||
using var gate = new SemaphoreSlim(MaxParallelFetches);
|
|
||||||
|
|
||||||
var tasks = timestamps.Select(async ts =>
|
|
||||||
{
|
|
||||||
await gate.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var price = await FetchPriceAt(bucket, ts);
|
|
||||||
return price.HasValue ? new PricePoint { Timestamp = ts, Price = price.Value } : null;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
gate.Release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var results = await Task.WhenAll(tasks);
|
|
||||||
return results.Where(p => p != null).Select(p => p!).OrderBy(p => p.Timestamp).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Double?> FetchPriceAt(S3Bucket bucket, Int64 ts)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var raw = await bucket.Path($"{ts}.json").GetObjectAsString();
|
|
||||||
var json = DecodeChunk(raw);
|
|
||||||
return json == null ? null : ExtractCurrentPrice(json);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null; // missing chunk / decode error -> just skip this slot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunk objects are Base64-encoded ZIP archives whose inner "data.json" holds the record.
|
|
||||||
private static String? DecodeChunk(String raw)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var trimmed = raw.Trim();
|
|
||||||
if (trimmed.StartsWith('{'))
|
|
||||||
return raw; // defensive: already plain JSON
|
|
||||||
|
|
||||||
var bytes = Convert.FromBase64String(trimmed);
|
|
||||||
using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
|
|
||||||
var entry = zip.GetEntry("data.json");
|
|
||||||
if (entry == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
using var reader = new StreamReader(entry.Open());
|
|
||||||
return reader.ReadToEnd();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// data.json is a line-based dump (same format the frontend parseChunkJson consumes):
|
|
||||||
// Timestamp;<unix>;
|
|
||||||
// {"Config":{"CurrentPrice":0.19,...},"InverterRecord":{...}}
|
|
||||||
// i.e. a header line then a JSON-record line, possibly repeated. It is NOT a single
|
|
||||||
// JSON object, so parse line by line and return CurrentPrice from the last record.
|
|
||||||
private static Double? ExtractCurrentPrice(String dataJson)
|
|
||||||
{
|
|
||||||
Double? last = null;
|
|
||||||
foreach (var line in dataJson.Split('\n'))
|
|
||||||
{
|
|
||||||
var price = TryReadLinePrice(line.Trim());
|
|
||||||
if (price.HasValue)
|
|
||||||
last = price;
|
|
||||||
}
|
|
||||||
return last;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Double? TryReadLinePrice(String line)
|
|
||||||
{
|
|
||||||
if (line.Length == 0 || line[0] != '{')
|
|
||||||
return null; // skip "Timestamp;...;" headers and blank lines
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(line);
|
|
||||||
if (doc.RootElement.ValueKind != JsonValueKind.Object
|
|
||||||
|| !doc.RootElement.TryGetProperty("Config", out var config)
|
|
||||||
|| !config.TryGetProperty("CurrentPrice", out var currentPrice))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return currentPrice.ValueKind switch
|
|
||||||
{
|
|
||||||
JsonValueKind.Number => currentPrice.GetDouble(),
|
|
||||||
JsonValueKind.String when Double.TryParse(currentPrice.GetString(),
|
|
||||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) => parsed,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null; // skip a malformed record line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
|
||||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import ReactApexChart from 'react-apexcharts';
|
|
||||||
import ApexCharts, { ApexOptions } from 'apexcharts';
|
|
||||||
import { UnixTime, TimeSpan } from 'src/dataCache/time';
|
|
||||||
import {
|
|
||||||
CurrentPriceSeries,
|
|
||||||
fetchCurrentPriceHistory
|
|
||||||
} from './currentPriceData';
|
|
||||||
|
|
||||||
interface CurrentPriceChartProps {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HISTORY_DAYS = 7;
|
|
||||||
const CHART_ID = 'current-price-history';
|
|
||||||
|
|
||||||
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.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]);
|
|
||||||
|
|
||||||
// Snap the x-axis back to the full history window after the user drag-zooms.
|
|
||||||
const handleResetZoom = () => {
|
|
||||||
if (!series || series.data.length === 0) return;
|
|
||||||
const minX = series.data[0][0];
|
|
||||||
const maxX = series.data[series.data.length - 1][0];
|
|
||||||
ApexCharts.exec(CHART_ID, 'zoomX', minX, maxX);
|
|
||||||
};
|
|
||||||
|
|
||||||
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: CHART_ID,
|
|
||||||
type: 'line',
|
|
||||||
height: 300,
|
|
||||||
// Toolbar hidden — its zoom glyphs are easy to miss; we expose an explicit
|
|
||||||
// "Reset zoom" button instead. Drag-to-zoom still works via zoom.enabled.
|
|
||||||
toolbar: { show: false },
|
|
||||||
zoom: { enabled: true, type: 'x', 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 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
mb: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
<FormattedMessage
|
|
||||||
id="currentPriceHistory"
|
|
||||||
defaultMessage="Current Price (last 7 days)"
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RestartAltIcon />}
|
|
||||||
onClick={handleResetZoom}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="resetZoom" defaultMessage="Reset zoom" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<ReactApexChart
|
|
||||||
options={options}
|
|
||||||
series={chartSeries}
|
|
||||||
type="line"
|
|
||||||
height={300}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CurrentPriceChart;
|
|
||||||
|
|
@ -43,7 +43,6 @@ 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 CurrentPriceChart from './CurrentPriceChart';
|
|
||||||
|
|
||||||
interface SodistoreHomeConfigurationProps {
|
interface SodistoreHomeConfigurationProps {
|
||||||
values: JSONRecordData;
|
values: JSONRecordData;
|
||||||
|
|
@ -962,10 +961,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '15px', marginTop: '10px' }}>
|
|
||||||
<CurrentPriceChart id={props.id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'priceToSell' })}
|
label={intl.formatMessage({ id: 'priceToSell' })}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { AxiosResponse } from 'axios';
|
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
|
||||||
import { UnixTime } from 'src/dataCache/time';
|
|
||||||
|
|
||||||
// History of the `/Config/CurrentPrice` value (CHF/kWh) for the Configuration tab chart.
|
|
||||||
// The backend GetCurrentPriceHistory endpoint reads the per-15-minute price from S3
|
|
||||||
// chunks server-side and returns ready-to-plot points, so the browser makes one call.
|
|
||||||
|
|
||||||
export interface CurrentPriceSeries {
|
|
||||||
// [timestampMs, price] — timestampMs is local-time-shifted like the other charts
|
|
||||||
data: [number, number][];
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricePointDto {
|
|
||||||
timestamp: number; // unix seconds (UTC)
|
|
||||||
price: number; // CHF/kWh
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: CurrentPriceSeries = { data: [], min: 0, max: 0 };
|
|
||||||
|
|
||||||
// Match the other dashboards: render unix-second timestamps shifted into the
|
|
||||||
// browser's local zone so the x-axis reads as local time.
|
|
||||||
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 (
|
|
||||||
id: number,
|
|
||||||
start: UnixTime,
|
|
||||||
end: UnixTime
|
|
||||||
): Promise<CurrentPriceSeries> => {
|
|
||||||
let points: PricePointDto[] = [];
|
|
||||||
try {
|
|
||||||
const res: AxiosResponse<PricePointDto[]> = await axiosConfig.get(
|
|
||||||
`/GetCurrentPriceHistory?id=${id}&start=${start.ticks}&end=${end.ticks}`
|
|
||||||
);
|
|
||||||
points = res.data ?? [];
|
|
||||||
} catch {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: [number, number][] = [];
|
|
||||||
let min = Number.POSITIVE_INFINITY;
|
|
||||||
let max = Number.NEGATIVE_INFINITY;
|
|
||||||
|
|
||||||
points.forEach((p) => {
|
|
||||||
if (!p || p.price === undefined || p.price === null || Number.isNaN(p.price)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data.push([toLocalMs(p.timestamp), p.price]);
|
|
||||||
if (p.price < min) min = p.price;
|
|
||||||
if (p.price > max) max = p.price;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.length === 0) return EMPTY;
|
|
||||||
data.sort((a, b) => a[0] - b[0]);
|
|
||||||
return { data, min, max };
|
|
||||||
};
|
|
||||||
|
|
@ -590,9 +590,6 @@
|
||||||
"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.",
|
|
||||||
"resetZoom": "Zoom zurücksetzen",
|
|
||||||
"priceToSell": "Verkaufspreis",
|
"priceToSell": "Verkaufspreis",
|
||||||
"priceToBuy": "Kaufpreis",
|
"priceToBuy": "Kaufpreis",
|
||||||
"timeToSell": "Verkaufszeit",
|
"timeToSell": "Verkaufszeit",
|
||||||
|
|
|
||||||
|
|
@ -338,9 +338,6 @@
|
||||||
"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.",
|
|
||||||
"resetZoom": "Reset zoom",
|
|
||||||
"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,9 +590,6 @@
|
||||||
"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.",
|
|
||||||
"resetZoom": "Réinitialiser le zoom",
|
|
||||||
"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,9 +590,6 @@
|
||||||
"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.",
|
|
||||||
"resetZoom": "Reimposta zoom",
|
|
||||||
"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