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 (
-
-
+
+
);
}