Skip to content

Commit

Permalink
Merge pull request #549 from openclimatefix/development
Browse files Browse the repository at this point in the history
✨  Quartz Solar v0.5.4 – Staging
  • Loading branch information
braddf authored Dec 18, 2024
2 parents c3eb6be + d75cd7d commit a987fe0
Show file tree
Hide file tree
Showing 24 changed files with 585,290 additions and 247 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import DataLoadingChartStatus from "../DataLoadingChartStatus";

const GspDeltaColumn: FC<{
gspDeltas: Map<string, GspDeltaValue> | undefined;
setClickedGspId: Dispatch<SetStateAction<number | undefined>>;
setClickedGspId: Dispatch<SetStateAction<number | string | undefined>>;
negative?: boolean;
}> = ({ gspDeltas, setClickedGspId, negative = false }) => {
const [selectedBuckets] = useGlobalState("selectedBuckets");
Expand Down
33 changes: 25 additions & 8 deletions apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ import useGlobalState, { get30MinNow, getNext30MinSlot } from "../../helpers/glo
import Spinner from "../../icons/spinner";
import { ForecastValue } from "../../types";
import React, { FC } from "react";
import { NationalAggregation } from "../../map/types";
import { getTicks } from "../../helpers/chartUtils";

// We want to have the ymax of the graph to be related to the capacity of the GspPvRemixChart
// Static constant below of this function so we don't call dynamically unnecessarily.
// import { generateYMaxTickArray } from "../../helpers/chartUtils";
// console.log("Y_MAX_TICKS", generateYMaxTickArray());
//
// We want to have the yMax of the graph to be related to the capacity of the GspPvRemixChart.
// If we use the raw values, the graph looks funny, i.e y major ticks are 0 100 232
// So, we round these up to the following numbers
const yMax_levels = [
3, 9, 20, 28, 36, 45, 60, 80, 100, 120, 160, 200, 240, 300, 320, 360, 400, 450, 600
// So, we round these up to the following numbers, which hopefully split nicely into the y-axis.
// Uncomment the above function to get updated values should we need to change these
const Y_MAX_TICKS = [
1, 2, 3, 4, 5, 6, 9, 10, 12, 15, 18, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 100, 150, 200, 250,
300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000,
6000, 7000, 8000, 9000, 10000, 12000, 14000, 15000, 16000, 18000, 20000
];

const GspPvRemixChart: FC<{
gspId: number;
gspId: number | string;
selectedTime: string;
close: () => void;
setTimeOfInterest: (t: string) => void;
Expand All @@ -39,7 +48,8 @@ const GspPvRemixChart: FC<{
visibleLines,
deltaView = false
}) => {
const {
const [nationalAggregationLevel] = useGlobalState("nationalAggregationLevel");
let {
errors,
pvRealDataAfter,
pvRealDataIn,
Expand Down Expand Up @@ -110,14 +120,20 @@ const GspPvRemixChart: FC<{

// set ymax to the installed capacity of the graph
let yMax = gspInstalledCapacity || 100;
yMax = getRoundedTickBoundary(yMax, yMax_levels);
yMax = getRoundedTickBoundary(yMax, Y_MAX_TICKS);

let title = nationalAggregationLevel === NationalAggregation.GSP ? gspName || "" : String(gspId);

if (nationalAggregationLevel === NationalAggregation.national) {
title = "National GSP Sum";
}

return (
<>
<div className="flex-initial">
<ForecastHeaderGSP
onClose={close}
title={gspName || ""}
title={title}
mwpercent={Math.round(pvPercentage)}
pvTimeOnly={convertISODateStringToLondonTime(latestPvActualDatetime)}
pvValue={Number(latestPvActualInMW)?.toFixed(1)}
Expand Down Expand Up @@ -156,6 +172,7 @@ const GspPvRemixChart: FC<{
visibleLines={visibleLines}
deltaView={deltaView}
deltaYMaxOverride={Math.ceil(Number(gspInstalledCapacity) / 200) * 100 || 500}
yTicks={getTicks(yMax, Y_MAX_TICKS)}
/>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,148 @@
import { API_PREFIX, getAllForecastUrl } from "../../../constant";
import { FcAllResData, ForecastData, GspEntities, PvRealData } from "../../types";
import dnoGspGroupings from "../../../data/dno_gsp_groupings.json";
import ngGspZoneGroupings from "../../../data/ng_gsp_zone_groupings.json";
import nationalGspZone from "../../../data/national_gsp_zone.json";
import useGlobalState from "../../helpers/globalState";
import { useLoadDataFromApi } from "../../hooks/useLoadDataFromApi";
import { NationalAggregation } from "../../map/types";
import { components } from "../../../types/quartz-api";

const useGetGspData = (gspId: number) => {
const aggregateTruthData = (
pvDataRaw: components["schemas"]["GSPYieldGroupByDatetime"][] | undefined,
gspIds: number[],
key: string
) => {
if (!pvDataRaw?.length) return [];
return pvDataRaw?.map((d) => {
return {
datetimeUtc: d.datetimeUtc,
[key]: Number(
gspIds.reduce((acc, gspId) => {
if (!d.generationKwByGspId?.[gspId]) return acc;
const value = Number(d.generationKwByGspId[gspId]);
if (isNaN(value)) return acc;
return acc + value;
}, 0)
)
};
}) as PvRealData;
};
const aggregateForecastData = (
pvDataRaw: components["schemas"]["OneDatetimeManyForecastValues"][] | undefined,
gspIds: number[]
) => {
if (!pvDataRaw?.length) return [];
return pvDataRaw?.map((d) => {
return {
targetTime: d.datetimeUtc,
expectedPowerGenerationMegawatts: Number(
gspIds.reduce((acc, gspId) => {
if (!d.forecastValues?.[gspId]) return acc;
const value = Number(d.forecastValues[gspId]);
if (isNaN(value)) return acc;
return acc + value;
}, 0)
)
};
}) as ForecastData;
};

const useGetGspData = (gspId: number | string) => {
const [show4hView] = useGlobalState("showNHourView");
const [nHourForecast] = useGlobalState("nHourForecast");
const [nationalAggregationLevel] = useGlobalState("nationalAggregationLevel");
let errors: Error[] = [];
let isZoneAggregation = [
NationalAggregation.DNO,
NationalAggregation.zone,
NationalAggregation.national
].includes(nationalAggregationLevel);

const { data: pvRealDataIn, error: pvRealInDat } = useLoadDataFromApi<PvRealData>(
`${API_PREFIX}/solar/GB/gsp/pvlive/${gspId}?regime=in-day`
let gspIds: number[] = typeof gspId === "number" ? [gspId] : [];
if (nationalAggregationLevel === NationalAggregation.DNO) {
// Get the GSP ids for the DNO
gspIds = dnoGspGroupings[gspId as keyof typeof dnoGspGroupings] || [];
}
if (nationalAggregationLevel === NationalAggregation.zone) {
gspIds = ngGspZoneGroupings[gspId as keyof typeof ngGspZoneGroupings] || [];
}
if (nationalAggregationLevel === NationalAggregation.national) {
gspIds = nationalGspZone[gspId as keyof typeof nationalGspZone] || [];
}

const { data: pvRealDataInRaw, error: pvRealInDayError } = useLoadDataFromApi<
components["schemas"]["GSPYieldGroupByDatetime"][]
>(
`${API_PREFIX}/solar/GB/gsp/pvlive/all?regime=in-day&gsp_ids=${encodeURIComponent(
gspIds.join(",")
)}&compact=true`
);
const pvRealDataIn = aggregateTruthData(pvRealDataInRaw, gspIds, "solarGenerationKw");

const { data: pvRealDataAfter, error: pvRealDayAfter } = useLoadDataFromApi<PvRealData>(
`${API_PREFIX}/solar/GB/gsp/pvlive/${gspId}?regime=day-after`
const { data: pvRealDataAfterRaw, error: pvRealDayAfterError } = useLoadDataFromApi<
components["schemas"]["GSPYieldGroupByDatetime"][]
>(
`${API_PREFIX}/solar/GB/gsp/pvlive/all?regime=day-after&gsp_ids=${encodeURIComponent(
gspIds.join(",")
)}&compact=true`
);
const pvRealDataAfter = aggregateTruthData(pvRealDataAfterRaw, gspIds, "solarGenerationKw");

//add new useSWR for gspChartData
const { data: gspForecastDataOneGSP, error: gspForecastDataOneGSPError } =
useLoadDataFromApi<ForecastData>(`${API_PREFIX}/solar/GB/gsp/${gspId}/forecast`, {
const { data: gspForecastDataOneGSPRaw, error: gspForecastDataOneGSPError } = useLoadDataFromApi<
components["schemas"]["OneDatetimeManyForecastValues"][]
>(
`${API_PREFIX}/solar/GB/gsp/forecast/all/?gsp_ids=${encodeURIComponent(
gspIds.join(",")
)}&compact=true&historic=true`,
{
dedupingInterval: 1000 * 30
});
}
);
const gspForecastDataOneGSP = aggregateForecastData(gspForecastDataOneGSPRaw, gspIds);

//add new useSWR for gspLocationInfo since this is not
const { data: gspLocationInfo, error: gspLocationError } = useLoadDataFromApi<GspEntities>(
`${API_PREFIX}/system/GB/gsp/?gsp_id=${gspId}`
const { data: gspLocationInfoRaw, error: gspLocationError } = useLoadDataFromApi<GspEntities>(
isZoneAggregation
? `${API_PREFIX}/system/GB/gsp/?zones=true` // TODO: API seems to struggle with UI flag if no other query params
: `${API_PREFIX}/system/GB/gsp/?gsp_id=${gspId}`
);
let gspLocationInfo = gspLocationInfoRaw?.filter((gsp) => gspIds.includes(gsp.gspId));
if (isZoneAggregation && gspLocationInfo) {
const zoneCapacity = gspLocationInfo.reduce((acc, gsp) => acc + gsp.installedCapacityMw, 0);
gspLocationInfo = [
{
gspId: gspId as number,
gspName: gspId as string,
regionName: gspId as string,
installedCapacityMw: zoneCapacity,
rmMode: true,
label: "Zone",
gspGroup: "Zone"
}
];
}

// TODO: nHour with aggregation when /forecast/all API endpoint has new forecast_horizon_minutes param
const nMinuteForecast = nHourForecast * 60;
const { data: gspNHourData, error: pvNHourError } = useLoadDataFromApi<ForecastData>(
show4hView
const { data: gspNHourDataRaw, error: pvNHourError } = useLoadDataFromApi<
components["schemas"]["ForecastValue"][]
>(
show4hView && !isZoneAggregation
? `${API_PREFIX}/solar/GB/gsp/${gspId}/forecast?forecast_horizon_minutes=${nMinuteForecast}&historic=true&only_forecast_values=true`
: null
);
let gspNHourData = gspNHourDataRaw || [];

return {
errors: [
pvRealInDat,
pvRealDayAfter,
pvRealInDayError,
pvRealDayAfterError,
gspForecastDataOneGSPError,
gspLocationError,
pvNHourError
].filter((e) => !!e),
gspNHourData: gspNHourData,
gspNHourData,
pvRealDataIn,
pvRealDataAfter,
gspForecastDataOneGSP,
Expand Down
1 change: 1 addition & 0 deletions apps/nowcasting-app/components/charts/pv-remix-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const PvRemixChart: FC<{
nationalNHourData,
allGspForecastData,
allGspRealData,
allGspSystemData,
gspDeltas
} = combinedData;
const {
Expand Down
5 changes: 4 additions & 1 deletion apps/nowcasting-app/components/charts/remix-line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type RemixLineProps = {
zoomEnabled?: boolean;
deltaView?: boolean;
deltaYMaxOverride?: number;
yTicks?: number[];
};
const CustomizedLabel: FC<any> = ({
value,
Expand Down Expand Up @@ -142,7 +143,8 @@ const RemixLine: React.FC<RemixLineProps> = ({
visibleLines,
zoomEnabled = true,
deltaView = false,
deltaYMaxOverride
deltaYMaxOverride,
yTicks
}) => {
// Set the y max. If national then set to 12000, for gsp plot use 'auto'
const preppedData = data.sort((a, b) => a.formattedDate.localeCompare(b.formattedDate));
Expand Down Expand Up @@ -412,6 +414,7 @@ const RemixLine: React.FC<RemixLineProps> = ({
yAxisId={"y-axis"}
tick={{ fill: "white", style: { fontSize: "12px" } }}
tickLine={false}
ticks={yTicks}
domain={
globalIsZoomed && view !== VIEWS.SOLAR_SITES
? [0, Number(zoomYMax * 1.1)]
Expand Down
89 changes: 89 additions & 0 deletions apps/nowcasting-app/components/helpers/chartUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,92 @@ export const getZoomYMax = (filteredPreppedData: ChartData[]) => {
.sort((a, b) => Number(b) - Number(a))[0];
}
};

// Function not "in use" but useful for regenerating yMax levels as a constant array for the chart
export const generateYMaxTickArray = () => {
// Generate yMax levels
// Small values
let yMax_levels = Array.from({ length: 4 }, (_, i) => i + 1);
// Multiples of 3
yMax_levels = [...yMax_levels, ...Array.from({ length: 6 }, (_, i) => (i + 1) * 3)];
// Multiples of 5
yMax_levels = [...yMax_levels, ...Array.from({ length: 6 }, (_, i) => (i + 1) * 5)];
// Multiples of 10
yMax_levels = [...yMax_levels, ...Array.from({ length: 5 }, (_, i) => (i + 1) * 10)];
// Multiples of 15
yMax_levels = [...yMax_levels, ...Array.from({ length: 6 }, (_, i) => (i + 1) * 15)];
// Multiples of 20
yMax_levels = [...yMax_levels, ...Array.from({ length: 5 }, (_, i) => (i + 1) * 20)];
// Multiples of 25
yMax_levels = [...yMax_levels, ...Array.from({ length: 3 }, (_, i) => (i + 1) * 25)];
// Multiples of 50
yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 50)];
// Multiples of 100
yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 100)];
// Multiples of 500
yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 500)];
// Multiples of 1000
yMax_levels = [...yMax_levels, ...Array.from({ length: 10 }, (_, i) => (i + 1) * 1000)];
// Remove duplicates
yMax_levels = [...new Set(yMax_levels)];
// Sort
yMax_levels.sort((a, b) => a - b);
return yMax_levels;
};

export const getTicks = (yMax: number, yMax_levels: number[]) => {
const ticks: number[] = [];
const third = yMax / 3;
const quarter = yMax / 4;
const fifth = yMax / 5;
const seventh = yMax / 7;
const testTicksToAdd = (fractionN: number) => {
let canSplit = true;
let tempTicks = [];
for (let i = fractionN; i <= yMax; i += fractionN) {
if (isRoundNumber(i) || i === yMax) {
tempTicks.push(i);
} else {
canSplit = false;
break;
}
}
if (canSplit) {
ticks.push(...tempTicks);
}
};
const isRoundNumber = (n: number) => {
if (n > 2000) {
return n % 500 === 0;
}
if (n > 1000) {
return n % 250 === 0 || n % 100 === 0;
}
if (n > 200) {
return n % 50 === 0;
}
if (n > 20) {
return n % 5 === 0;
}
if (n > 3) {
return n % 1 === 0;
}
return n % 0.5 === 0;
};
if (isRoundNumber(third)) {
testTicksToAdd(third);
}
if (ticks.length === 0 && isRoundNumber(quarter)) {
testTicksToAdd(quarter);
}
if (ticks.length === 0 && isRoundNumber(fifth)) {
testTicksToAdd(fifth);
}
if (ticks.length === 0 && isRoundNumber(seventh)) {
testTicksToAdd(seventh);
}
if (ticks.length === 0) {
testTicksToAdd(yMax > 500 ? 100 : 50);
}
return ticks;
};
Loading

0 comments on commit a987fe0

Please sign in to comment.