Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions windwatts-ui/public/gtm-init.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
(function (w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" });
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
dl = l != "dataLayer" ? "&l=" + l : "";
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-KG7PVKB');
})(window, document, "script", "dataLayer", "GTM-KG7PVKB");
199 changes: 128 additions & 71 deletions windwatts-ui/src/components/settings/HubHeightSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useContext, useEffect, useMemo } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
Comment thread
RLiNREL marked this conversation as resolved.
import { SettingsContext } from "../../providers/SettingsContext";
import {
Box,
IconButton,
InputAdornment,
Slider,
Typography,
Paper,
TextField,
Tooltip,
IconButton,
Typography,
} from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { resolveCustomCurve } from "../../utils";
import { resolveCustomCurve, resolveHubHeight } from "../../utils";
import { HUB_HEIGHTS, TURBINE_DATA } from "../../constants";

export function HubHeightSettings() {
Expand All @@ -21,48 +22,78 @@ export function HubHeightSettings() {
customCurves,
} = useContext(SettingsContext);

const { values: availableHeights, interpolation: step } = useMemo(() => {
if (dataModel && HUB_HEIGHTS[dataModel]) {
return HUB_HEIGHTS[dataModel];
}
return HUB_HEIGHTS.default;
}, [dataModel]);
const { values: availableHeights, interpolation: interpolable } =
useMemo(() => {
if (dataModel && HUB_HEIGHTS[dataModel]) {
return HUB_HEIGHTS[dataModel];
}
return HUB_HEIGHTS.default;
}, [dataModel]);

const modelMin = Math.min(...availableHeights);
const modelMax = Math.max(...availableHeights);

const [inputValue, setInputValue] = useState(String(hubHeight));

// ensure slider compatibility with model switching and available heights changes
// keep text field in sync when hubHeight changes externally (slider, model switch)
useEffect(() => {
if (!availableHeights.includes(hubHeight)) {
// if current height not available, set to the closest available height
const closestHeight = availableHeights.reduce((prev, curr) =>
Math.abs(curr - hubHeight) < Math.abs(prev - hubHeight) ? curr : prev
);
setHubHeight(closestHeight);
}
}, [availableHeights, hubHeight, setHubHeight]);
setInputValue(String(hubHeight));
}, [hubHeight]);

// clamp or snap on model switch / available heights change
useEffect(() => {
const resolved = resolveHubHeight(
hubHeight,
availableHeights,
interpolable
);
if (resolved !== hubHeight) setHubHeight(resolved);
}, [availableHeights, interpolable, hubHeight, setHubHeight]);

const hubHeightMarks = availableHeights.map((value: number) => ({
value: value,
value,
label: `${value}m`,
}));

const handleHubHeightChange = (
_: Event,
newHubHeight: number | number[] | null
) => {
if (newHubHeight !== null && typeof newHubHeight === "number") {
setHubHeight(newHubHeight);
const handleSliderChange = (_: Event, newValue: number | number[]) => {
if (typeof newValue === "number") {
setHubHeight(newValue);
}
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};

const commitInput = () => {
const parsed = parseInt(inputValue, 10);
if (!isNaN(parsed)) {
const resolvedHubHeight = resolveHubHeight(
parsed,
availableHeights,
interpolable
);
setHubHeight(resolvedHubHeight);
} else {
setInputValue(String(hubHeight));
}
};

const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") commitInput();
};

const customCurve = resolveCustomCurve(turbine, customCurves);
const turbineInfo = TURBINE_DATA[turbine];

const minHeight = customCurve?.minHeight ?? turbineInfo?.minHeight;
const maxHeight = customCurve?.maxHeight ?? turbineInfo?.maxHeight;
const hasHeightRange = minHeight !== undefined && maxHeight !== undefined;
const turbineMinHeight = customCurve?.minHeight ?? turbineInfo?.minHeight;
const turbineMaxHeight = customCurve?.maxHeight ?? turbineInfo?.maxHeight;
const hasHeightRange =
turbineMinHeight !== undefined && turbineMaxHeight !== undefined;
const heightRangeInfo = turbineInfo?.info ?? "";

const isHeightInRange: boolean = hasHeightRange
? hubHeight >= minHeight! && hubHeight <= maxHeight!
? hubHeight >= turbineMinHeight! && hubHeight <= turbineMaxHeight!
: true;

const validationColor: "primary" | "success" | "warning" = hasHeightRange
Expand All @@ -76,50 +107,76 @@ export function HubHeightSettings() {
<Typography variant="h6" gutterBottom>
Hub Height
</Typography>
<Typography variant="body2" gutterBottom>
Choose nearest hub height (m):
</Typography>

{hasHeightRange && (
<Paper
sx={{
p: 1,
mb: 2,
backgroundColor: `${validationColor}.light`,
borderLeft: `4px solid`,
borderLeftColor: `${validationColor}.main`,
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2">Set hub height:</Typography>
<TextField
value={inputValue}
onChange={handleInputChange}
onBlur={commitInput}
onKeyDown={handleInputKeyDown}
size="small"
type="number"
disabled={!interpolable}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">m</InputAdornment>,
},
htmlInput: {
min: modelMin,
max: modelMax,
step: 1,
"aria-label": "hub height input",
},
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body2">
<strong>
{isHeightInRange ? "Within" : "Outside"} recommended range - (
{minHeight}m - {maxHeight}m)
</strong>
</Typography>
{heightRangeInfo && (
<Tooltip title={heightRangeInfo} arrow placement="right">
<IconButton size="small">
<InfoOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</Paper>
sx={{ width: 100 }}
/>
</Box>

<Box sx={{ px: 1 }}>
<Slider
value={hubHeight}
onChange={handleSliderChange}
aria-labelledby="hub-height-slider"
valueLabelDisplay="auto"
getAriaValueText={(value) => `${value}m`}
step={interpolable ? 1 : null}
marks={hubHeightMarks}
min={modelMin}
max={modelMax}
color={validationColor}
/>
</Box>

{interpolable && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
* Values between marks are interpolated (no extrapolation).
</Typography>
)}

<Slider
value={hubHeight}
onChange={handleHubHeightChange}
aria-labelledby="hub-height-slider"
valueLabelDisplay="auto"
getAriaValueText={(value) => `${value}m`}
step={step}
marks={hubHeightMarks}
min={Math.min(...availableHeights)}
max={Math.max(...availableHeights)}
color={validationColor}
/>
{hasHeightRange && (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="caption"
sx={{ color: `${validationColor}.main` }}
>
* Recommended range: {turbineMinHeight}m - {turbineMaxHeight}m
</Typography>
{heightRangeInfo && (
<Tooltip title={heightRangeInfo} arrow placement="right">
<IconButton size="small">
<InfoOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function LossAssumptionSettings() {
Loss Assumption
</Typography>
<Typography variant="body2" mb={2}>
Set an energy percent loss (17% recommended).
Set an energy percent loss (17% recommended):
</Typography>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<FormControlLabel
Expand Down
13 changes: 8 additions & 5 deletions windwatts-ui/src/constants/hubSettings.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { DataModel, Heights } from "../types";

export const HUB_HEIGHTS: Record<DataModel | "default", Heights> = {
"era5-quantiles": { values: [30, 40, 50, 60, 80, 100], interpolation: null },
"wtk-timeseries": { values: [40, 60, 80, 100, 120, 140], interpolation: 10 },
"era5-quantiles": { values: [30, 40, 50, 60, 80, 100], interpolation: true },
"wtk-timeseries": {
values: [40, 60, 80, 100, 120, 140],
interpolation: true,
},
"ensemble-quantiles": {
values: [30, 40, 50, 60, 80, 100],
interpolation: null,
interpolation: true,
},
"era5-timeseries": { values: [30, 40, 50, 60, 80, 100], interpolation: null },
default: { values: [40, 60, 80, 100], interpolation: null },
"era5-timeseries": { values: [30, 40, 50, 60, 80, 100], interpolation: true },
default: { values: [40, 60, 80, 100], interpolation: false },
};
2 changes: 1 addition & 1 deletion windwatts-ui/src/types/Heights.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface Heights {
values: number[];
interpolation: number | null;
interpolation: boolean;
}
19 changes: 19 additions & 0 deletions windwatts-ui/src/utils/turbine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ export const parseOptionalHeight = (value: string): number | undefined => {
return trimmed ? Number(trimmed) : undefined;
};

/**
* Resolves a raw hub height value to a valid value for the given model.
* - Interpolable model: clamps to [min, max] — no extrapolation.
* - Non-interpolable model: clamps then snaps to the nearest available height.
*/
export const resolveHubHeight = (
value: number,
availableHeights: number[],
interpolable: boolean
): number => {
const min = Math.min(...availableHeights);
const max = Math.max(...availableHeights);
const clamped = Math.max(min, Math.min(max, value));
if (interpolable) return clamped;
return availableHeights.reduce((prev, curr) =>
Math.abs(curr - clamped) < Math.abs(prev - clamped) ? curr : prev
);
};

/**
* Validates an optional hub height range.
* Returns an error message string, or null when the values are valid.
Expand Down