From d8feef5431f38c1b76c80f6ef2d9fb1631a973fe Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 16 May 2025 13:44:14 -0500 Subject: [PATCH] web: Allow changing parameters --- web/.prettierrc.json | 1 + .../CaseBuilder/Buses/BusOperations.test.ts | 14 ++ .../CaseBuilder/Buses/BusesTable.tsx | 7 +- .../components/CaseBuilder/CaseBuilder.tsx | 32 +++- .../Parameters/ParameterOperations.test.ts | 147 ++++++++++++++++++ .../Parameters/ParameterOperations.ts | 123 +++++++++++++++ .../CaseBuilder/Parameters/Parameters.tsx | 18 ++- .../components/Common/Forms/TextInputRow.tsx | 44 ++++-- 8 files changed, 362 insertions(+), 24 deletions(-) create mode 100644 web/.prettierrc.json create mode 100644 web/src/components/CaseBuilder/Parameters/ParameterOperations.test.ts create mode 100644 web/src/components/CaseBuilder/Parameters/ParameterOperations.ts diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1 @@ +{} diff --git a/web/src/components/CaseBuilder/Buses/BusOperations.test.ts b/web/src/components/CaseBuilder/Buses/BusOperations.test.ts index 8cf2510..f9c8a58 100644 --- a/web/src/components/CaseBuilder/Buses/BusOperations.test.ts +++ b/web/src/components/CaseBuilder/Buses/BusOperations.test.ts @@ -27,6 +27,20 @@ export const BUS_TEST_DATA_1: UnitCommitmentScenario = { }, }; +export const BUS_TEST_DATA_2: UnitCommitmentScenario = { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 2, + "Time step (min)": 30, + }, + Buses: { + b1: { "Load (MW)": [30, 30, 30, 30] }, + b2: { "Load (MW)": [10, 20, 30, 40] }, + b3: { "Load (MW)": [0, 30, 0, 40] }, + }, +}; + test("createBus", () => { const newScenario = createBus(BUS_TEST_DATA_1); assert.deepEqual(Object.keys(newScenario.Buses), ["b1", "b2", "b3", "b4"]); diff --git a/web/src/components/CaseBuilder/Buses/BusesTable.tsx b/web/src/components/CaseBuilder/Buses/BusesTable.tsx index 82a889b..e82a69f 100644 --- a/web/src/components/CaseBuilder/Buses/BusesTable.tsx +++ b/web/src/components/CaseBuilder/Buses/BusesTable.tsx @@ -50,7 +50,7 @@ const generateBusesTableColumns = ( ...columnsCommonAttrs, title: "Name", field: "Name", - width: 150, + minWidth: 150, }, ]; for ( @@ -65,7 +65,10 @@ const generateBusesTableColumns = ( ...columnsCommonAttrs, title: `Load (MW)
${formattedTime}
`, field: `Load ${offset}`, - width: 100, + minWidth: 100, + formatter: (cell) => { + return parseFloat(cell.getValue()).toFixed(2); + }, }); } return columns; diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index e620443..4b72f48 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -25,6 +25,10 @@ import { deleteBus, renameBus, } from "./Buses/BusOperations"; +import { + changeTimeHorizon, + changeTimeStep, +} from "./Parameters/ParameterOperations"; const CaseBuilder = () => { const [scenario, setScenario] = useState(TEST_SCENARIO); @@ -90,11 +94,37 @@ const CaseBuilder = () => { setScenario(scenario); }; + const onParameterChanged = (key: string, value: string) => { + if (key === "Time horizon (h)") { + const [newScenario, err] = changeTimeHorizon(scenario, value); + if (err) { + return err; + } + setScenario(newScenario); + return null; + } + + if (key === "Time step (min)") { + const [newScenario, err] = changeTimeStep(scenario, value); + if (err) { + return err; + } + setScenario(newScenario); + return null; + } + + console.log("onParameterChanged", key, value); + return null; + }; + return (
- + { + const [newScenario, err] = changeTimeHorizon(BUS_TEST_DATA_1, "3"); + assert(err === null); + assert.deepEqual(newScenario, { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 3, + "Time step (min)": 60, + }, + Buses: { + b1: { "Load (MW)": [35.79534, 34.38835, 33.45083] }, + b2: { "Load (MW)": [14.03739, 13.48563, 13.11797] }, + b3: { "Load (MW)": [27.3729, 26.29698, 25.58005] }, + }, + }); +}); + +test("changeTimeHorizon: Shrink 2", () => { + const [newScenario, err] = changeTimeHorizon(BUS_TEST_DATA_2, "1"); + assert(err === null); + assert.deepEqual(newScenario, { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 1, + "Time step (min)": 30, + }, + Buses: { + b1: { "Load (MW)": [30, 30] }, + b2: { "Load (MW)": [10, 20] }, + b3: { "Load (MW)": [0, 30] }, + }, + }); +}); + +test("changeTimeHorizon grow", () => { + const [newScenario, err] = changeTimeHorizon(BUS_TEST_DATA_1, "7"); + assert(err === null); + assert.deepEqual(newScenario, { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 7, + "Time step (min)": 60, + }, + Buses: { + b1: { + "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044, 0, 0], + }, + b2: { + "Load (MW)": [14.03739, 13.48563, 13.11797, 12.9009, 13.03939, 0, 0], + }, + b3: { + "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268, 0, 0], + }, + }, + }); +}); + +test("changeTimeHorizon invalid", () => { + let [, err] = changeTimeHorizon(BUS_TEST_DATA_1, "x"); + assert(err !== null); + assert.equal(err.message, "Invalid value: x"); + + [, err] = changeTimeHorizon(BUS_TEST_DATA_1, "-3"); + assert(err !== null); + assert.equal(err.message, "Invalid value: -3"); +}); + +test("evaluatePwlFunction", () => { + const data_x = [0, 60, 120, 180]; + const data_y = [100, 200, 250, 100]; + assert.equal(evaluatePwlFunction(data_x, data_y, 0), 100); + assert.equal(evaluatePwlFunction(data_x, data_y, 15), 125); + assert.equal(evaluatePwlFunction(data_x, data_y, 30), 150); + assert.equal(evaluatePwlFunction(data_x, data_y, 60), 200); + assert.equal(evaluatePwlFunction(data_x, data_y, 180), 100); +}); + +test("changeTimeStep", () => { + let [scenario, err] = changeTimeStep(BUS_TEST_DATA_2, "15"); + assert(err === null); + assert.deepEqual(scenario, { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 2, + "Time step (min)": 15, + }, + Buses: { + b1: { "Load (MW)": [30, 30, 30, 30, 30, 30, 30, 30] }, + b2: { "Load (MW)": [10, 15, 20, 25, 30, 35, 40, 25] }, + b3: { "Load (MW)": [0, 15, 30, 15, 0, 20, 40, 20] }, + }, + }); + + [scenario, err] = changeTimeStep(BUS_TEST_DATA_2, "60"); + assert(err === null); + assert.deepEqual(scenario, { + Parameters: { + Version: "0.4", + "Power balance penalty ($/MW)": 1000.0, + "Time horizon (h)": 2, + "Time step (min)": 60, + }, + Buses: { + b1: { "Load (MW)": [30, 30] }, + b2: { "Load (MW)": [10, 30] }, + b3: { "Load (MW)": [0, 0] }, + }, + }); +}); + +test("changeTimeStep invalid", () => { + let [, err] = changeTimeStep(BUS_TEST_DATA_2, "x"); + assert(err !== null); + assert.equal(err.message, "Invalid value: x"); + + [, err] = changeTimeStep(BUS_TEST_DATA_2, "-10"); + assert(err !== null); + assert.equal(err.message, "Invalid value: -10"); + + [, err] = changeTimeStep(BUS_TEST_DATA_2, "120"); + assert(err !== null); + assert.equal(err.message, "Invalid value: 120"); + + [, err] = changeTimeStep(BUS_TEST_DATA_2, "7"); + assert(err !== null); + assert.equal(err.message, "Time step must be a divisor of 60: 7"); +}); + +export {}; diff --git a/web/src/components/CaseBuilder/Parameters/ParameterOperations.ts b/web/src/components/CaseBuilder/Parameters/ParameterOperations.ts new file mode 100644 index 0000000..1ad44e0 --- /dev/null +++ b/web/src/components/CaseBuilder/Parameters/ParameterOperations.ts @@ -0,0 +1,123 @@ +/* + * UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment + * Copyright (C) 2020-2025, UChicago Argonne, LLC. All rights reserved. + * Released under the modified BSD license. See COPYING.md for more details. + */ + +import { Buses, UnitCommitmentScenario } from "../../../core/data"; +import { ValidationError } from "../../../core/Validation/validate"; + +export const changeTimeHorizon = ( + scenario: UnitCommitmentScenario, + newTimeHorizonStr: string, +): [UnitCommitmentScenario, ValidationError | null] => { + // Parse string + const newTimeHorizon = parseInt(newTimeHorizonStr); + if (isNaN(newTimeHorizon) || newTimeHorizon <= 0) { + return [scenario, { message: `Invalid value: ${newTimeHorizonStr}` }]; + } + const newScenario = JSON.parse( + JSON.stringify(scenario), + ) as UnitCommitmentScenario; + newScenario.Parameters["Time horizon (h)"] = newTimeHorizon; + const newT = (newTimeHorizon * 60) / scenario.Parameters["Time step (min)"]; + const oldT = + (scenario.Parameters["Time horizon (h)"] * 60) / + scenario.Parameters["Time step (min)"]; + if (newT < oldT) { + Object.values(newScenario.Buses).forEach((bus) => { + bus["Load (MW)"] = bus["Load (MW)"].slice(0, newT); + }); + } else { + const padding = Array(newT - oldT).fill(0); + Object.values(newScenario.Buses).forEach((bus) => { + bus["Load (MW)"] = bus["Load (MW)"].concat(padding); + }); + } + return [newScenario, null]; +}; + +export const evaluatePwlFunction = ( + data_x: number[], + data_y: number[], + x: number, +) => { + if (x < data_x[0]! || x > data_x[data_x.length - 1]!) { + throw Error("PWL interpolation: Out of bounds"); + } + + if (x === data_x[0]) return data_y[0]; + + // Binary search to find the interval containing x + let low = 0; + let high = data_x.length - 1; + while (low < high) { + let mid = Math.floor((low + high) / 2); + if (data_x[mid]! < x) low = mid + 1; + else high = mid; + } + + // Linear interpolation within the found interval + const x1 = data_x[low - 1]!; + const y1 = data_y[low - 1]!; + const x2 = data_x[low]!; + const y2 = data_y[low]!; + + return y1 + ((x - x1) * (y2 - y1)) / (x2 - x1); +}; + +export const changeTimeStep = ( + scenario: UnitCommitmentScenario, + newTimeStepStr: string, +): [UnitCommitmentScenario, ValidationError | null] => { + // Parse string and perform validation + const newTimeStep = parseFloat(newTimeStepStr); + if (isNaN(newTimeStep) || newTimeStep < 1 || newTimeStep > 60) { + return [scenario, { message: `Invalid value: ${newTimeStepStr}` }]; + } + if (60 % newTimeStep !== 0) { + return [ + scenario, + { message: `Time step must be a divisor of 60: ${newTimeStepStr}` }, + ]; + } + + // Build data_x + let timeHorizon = scenario.Parameters["Time horizon (h)"]; + const oldTimeStep = scenario.Parameters["Time step (min)"]; + const oldT = (timeHorizon * 60) / oldTimeStep; + const newT = (timeHorizon * 60) / newTimeStep; + const data_x = Array(oldT + 1).fill(0); + for (let i = 0; i <= oldT; i++) data_x[i] = i * oldTimeStep; + + const newBuses: Buses = {}; + for (const busName in scenario.Buses) { + // Build data_y + const busLoad = scenario.Buses[busName]!["Load (MW)"]; + const data_y = Array(oldT + 1).fill(0); + for (let i = 0; i < oldT; i++) data_y[i] = busLoad[i]; + data_y[oldT] = data_y[0]; + + // Run interpolation + const newBusLoad = Array(newT).fill(0); + for (let i = 0; i < newT; i++) { + newBusLoad[i] = evaluatePwlFunction(data_x, data_y, newTimeStep * i); + } + newBuses[busName] = { + ...scenario.Buses[busName], + "Load (MW)": newBusLoad, + }; + } + + return [ + { + ...scenario, + Parameters: { + ...scenario.Parameters, + "Time step (min)": newTimeStep, + }, + Buses: newBuses, + }, + null, + ]; +}; diff --git a/web/src/components/CaseBuilder/Parameters/Parameters.tsx b/web/src/components/CaseBuilder/Parameters/Parameters.tsx index 18158d0..70c031c 100644 --- a/web/src/components/CaseBuilder/Parameters/Parameters.tsx +++ b/web/src/components/CaseBuilder/Parameters/Parameters.tsx @@ -8,12 +8,14 @@ import SectionHeader from "../../Common/SectionHeader/SectionHeader"; import Form from "../../Common/Forms/Form"; import TextInputRow from "../../Common/Forms/TextInputRow"; import { UnitCommitmentScenario } from "../../../core/data"; +import { ValidationError } from "../../../core/Validation/validate"; interface ParametersProps { scenario: UnitCommitmentScenario; + onParameterChanged: (key: string, value: string) => ValidationError | null; } -function Parameters({ scenario }: ParametersProps) { +function Parameters(props: ParametersProps) { return (
@@ -22,22 +24,24 @@ function Parameters({ scenario }: ParametersProps) { label="Time horizon" unit="h" tooltip="Length of the planning horizon (in hours)." - currentValue={`${scenario.Parameters["Time horizon (h)"]}`} - defaultValue="24" + initialValue={`${props.scenario.Parameters["Time horizon (h)"]}`} + onChange={(v) => props.onParameterChanged("Time horizon (h)", v)} /> props.onParameterChanged("Time step (min)", v)} /> + props.onParameterChanged("Power balance penalty ($/MW)", v) + } />
diff --git a/web/src/components/Common/Forms/TextInputRow.tsx b/web/src/components/Common/Forms/TextInputRow.tsx index 6e66f36..fd513b9 100644 --- a/web/src/components/Common/Forms/TextInputRow.tsx +++ b/web/src/components/Common/Forms/TextInputRow.tsx @@ -6,28 +6,44 @@ import formStyles from "./Form.module.css"; import HelpButton from "../Buttons/HelpButton"; +import React, { useRef, useState } from "react"; +import { ValidationError } from "../../../core/Validation/validate"; -function TextInputRow({ - label, - unit, - tooltip, - currentValue, - defaultValue, -}: { +interface TextInputRowProps { label: string; unit: string; tooltip: string; - currentValue: string; - defaultValue: string; -}) { + initialValue: string; + onChange: (newValue: string) => ValidationError | null; +} + +function TextInputRow(props: TextInputRowProps) { + const [savedValue, setSavedValue] = useState(props.initialValue); + const inputRef = useRef(null); + + const onBlur = (event: React.FocusEvent) => { + const newValue = event.target.value; + if (newValue === savedValue) return; + const err = props.onChange(newValue); + if (err) { + inputRef.current!.value = savedValue; + return; + } + setSavedValue(newValue); + }; return (
- - + +
); }