From 201dd34b307decb3766b0d440eaee145b1e848f3 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 10 Sep 2025 11:54:17 -0500 Subject: [PATCH] web: Add support for storage units --- .../components/CaseBuilder/CaseBuilder.tsx | 14 +- .../components/CaseBuilder/StorageUnits.tsx | 235 ++++++++++++++++++ web/src/core/Data/fixtures.test.ts | 21 ++ web/src/core/Data/fixtures.tsx | 1 + web/src/core/Data/types.tsx | 21 ++ web/src/core/Operations/commonOps.ts | 2 +- web/src/core/Operations/preprocessing.test.ts | 9 +- web/src/core/Operations/preprocessing.ts | 15 ++ web/src/core/Operations/storageOps.test.ts | 75 ++++++ web/src/core/Operations/storageOps.ts | 98 ++++++++ 10 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 web/src/components/CaseBuilder/StorageUnits.tsx create mode 100644 web/src/core/Operations/storageOps.test.ts create mode 100644 web/src/core/Operations/storageOps.ts diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index 989b958..28c660b 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -20,6 +20,7 @@ import ProfiledUnitsComponent from "./ProfiledUnits"; import ThermalUnitsComponent from "./ThermalUnits"; import TransmissionLinesComponent from "./TransmissionLines"; import { UnitCommitmentScenario } from "../../core/Data/types"; +import StorageComponent from "./StorageUnits"; export interface CaseBuilderSectionProps { scenario: UnitCommitmentScenario; @@ -30,7 +31,13 @@ export interface CaseBuilderSectionProps { const CaseBuilder = () => { const [scenario, setScenario] = useState(() => { const savedScenario = localStorage.getItem("scenario"); - return savedScenario ? JSON.parse(savedScenario) : BLANK_SCENARIO; + if (!savedScenario) return BLANK_SCENARIO; + const [processedScenario, err] = preprocess(JSON.parse(savedScenario)); + if (err) { + console.log(err); + return BLANK_SCENARIO; + } + return processedScenario!!; }); const [undoStack, setUndoStack] = useState([]); const [toastMessage, setToastMessage] = useState(""); @@ -112,6 +119,11 @@ const CaseBuilder = () => { onDataChanged={onDataChanged} onError={setToastMessage} /> + { + const columns = generateTableColumns(scenario, StorageUnitsColumnSpec); + const data = generateTableData( + scenario["Storage units"], + StorageUnitsColumnSpec, + scenario, + ); + return [data, columns]; +}; + +const StorageUnitsComponent = (props: CaseBuilderSectionProps) => { + const fileUploadElem = useRef(null); + + const onDownload = () => { + const [data, columns] = generateStorageUnitsData(props.scenario); + const csvContents = generateCsv(data, columns); + offerDownload(csvContents, "text/csv", "storage_units.csv"); + }; + + const onUpload = () => { + fileUploadElem.current!.showFilePicker((csv: any) => { + // Parse provided CSV file + const [storageUnits, err] = parseCsv( + csv, + StorageUnitsColumnSpec, + props.scenario, + ); + + // Handle validation errors + if (err) { + props.onError(err.message); + return; + } + + // Generate new scenario + props.onDataChanged({ + ...props.scenario, + "Storage units": storageUnits, + }); + }); + }; + + const onAdd = () => { + const [newScenario, err] = createStorageUnit(props.scenario); + if (err) { + props.onError(err.message); + return; + } + props.onDataChanged(newScenario); + }; + + const onDelete = (name: string): ValidationError | null => { + const newScenario = deleteStorageUnit(name, props.scenario); + props.onDataChanged(newScenario); + return null; + }; + + const onDataChanged = ( + name: string, + field: string, + newValue: string, + ): ValidationError | null => { + const [newScenario, err] = changeStorageUnitData( + name, + field, + newValue, + props.scenario, + ); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + + const onRename = ( + oldName: string, + newName: string, + ): ValidationError | null => { + const [newScenario, err] = renameStorageUnit( + oldName, + newName, + props.scenario, + ); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + + return ( +
+ + + + + + generateStorageUnitsData(props.scenario)} + /> + +
+ ); +}; + +export default StorageUnitsComponent; diff --git a/web/src/core/Data/fixtures.test.ts b/web/src/core/Data/fixtures.test.ts index fdb248f..350fe77 100644 --- a/web/src/core/Data/fixtures.test.ts +++ b/web/src/core/Data/fixtures.test.ts @@ -61,6 +61,25 @@ export const TEST_DATA_1: UnitCommitmentScenario = { "Flow limit penalty ($/MW)": 5000.0, }, }, + "Storage units": { + su1: { + Bus: "b1", + "Minimum level (MWh)": 10.0, + "Maximum level (MWh)": 100.0, + "Charge cost ($/MW)": 2.0, + "Discharge cost ($/MW)": 1.0, + "Charge efficiency": 0.8, + "Discharge efficiency": 0.85, + "Loss factor": 0.01, + "Minimum charge rate (MW)": 5.0, + "Maximum charge rate (MW)": 10.0, + "Minimum discharge rate (MW)": 4.0, + "Maximum discharge rate (MW)": 8.0, + "Initial level (MWh)": 20.0, + "Last period minimum level (MWh)": 21.0, + "Last period maximum level (MWh)": 22.0, + }, + }, }; export const TEST_DATA_2: UnitCommitmentScenario = { @@ -77,6 +96,7 @@ export const TEST_DATA_2: UnitCommitmentScenario = { }, Generators: {}, "Transmission lines": {}, + "Storage units": {}, }; export const TEST_DATA_BLANK: UnitCommitmentScenario = { @@ -89,6 +109,7 @@ export const TEST_DATA_BLANK: UnitCommitmentScenario = { Buses: {}, Generators: {}, "Transmission lines": {}, + "Storage units": {}, }; test("fixtures", () => {}); diff --git a/web/src/core/Data/fixtures.tsx b/web/src/core/Data/fixtures.tsx index 31789ad..3b2cf73 100644 --- a/web/src/core/Data/fixtures.tsx +++ b/web/src/core/Data/fixtures.tsx @@ -20,4 +20,5 @@ export const BLANK_SCENARIO: UnitCommitmentScenario = { Buses: {}, Generators: {}, "Transmission lines": {}, + "Storage units": {}, }; diff --git a/web/src/core/Data/types.tsx b/web/src/core/Data/types.tsx index 435b71b..c49433f 100644 --- a/web/src/core/Data/types.tsx +++ b/web/src/core/Data/types.tsx @@ -45,6 +45,24 @@ export interface TransmissionLine { "Flow limit penalty ($/MW)": number; } +export interface StorageUnit { + Bus: string; + "Minimum level (MWh)": number; + "Maximum level (MWh)": number; + "Charge cost ($/MW)": number; + "Discharge cost ($/MW)": number; + "Charge efficiency": number; + "Discharge efficiency": number; + "Loss factor": number; + "Minimum charge rate (MW)": number; + "Maximum charge rate (MW)": number; + "Minimum discharge rate (MW)": number; + "Maximum discharge rate (MW)": number; + "Initial level (MWh)": number; + "Last period minimum level (MWh)": number; + "Last period maximum level (MWh)": number; +} + export interface UnitCommitmentScenario { Parameters: { Version: string; @@ -57,6 +75,9 @@ export interface UnitCommitmentScenario { "Transmission lines": { [name: string]: TransmissionLine; }; + "Storage units": { + [name: string]: StorageUnit; + }; } const getTypedGenerators = ( diff --git a/web/src/core/Operations/commonOps.ts b/web/src/core/Operations/commonOps.ts index e69ca72..a748d01 100644 --- a/web/src/core/Operations/commonOps.ts +++ b/web/src/core/Operations/commonOps.ts @@ -253,6 +253,6 @@ export const assertBusesNotEmpty = ( scenario: UnitCommitmentScenario, ): ValidationError | null => { if (Object.keys(scenario.Buses).length === 0) - return { message: "Profiled unit requires an existing bus." }; + return { message: "This component requires an existing bus." }; return null; }; diff --git a/web/src/core/Operations/preprocessing.test.ts b/web/src/core/Operations/preprocessing.test.ts index 0398491..6e810e3 100644 --- a/web/src/core/Operations/preprocessing.test.ts +++ b/web/src/core/Operations/preprocessing.test.ts @@ -20,7 +20,8 @@ export const PREPROCESSING_TEST_DATA_1: any = { }; test("preprocess", () => { - const newScenario = preprocess(PREPROCESSING_TEST_DATA_1); + const [newScenario, err] = preprocess(PREPROCESSING_TEST_DATA_1); + assert(err === null); assert.deepEqual(newScenario, { Parameters: { Version: "0.4", @@ -35,5 +36,11 @@ test("preprocess", () => { b2: { "Load (MW)": [10, 10, 10, 10, 10] }, b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] }, }, + "Price-sensitive loads": {}, + "Storage units": {}, + "Transmission lines": {}, + Contingencies: {}, + Generators: {}, + Reserves: {}, }); }); diff --git a/web/src/core/Operations/preprocessing.ts b/web/src/core/Operations/preprocessing.ts index 78cc4c4..8aaad31 100644 --- a/web/src/core/Operations/preprocessing.ts +++ b/web/src/core/Operations/preprocessing.ts @@ -41,6 +41,21 @@ export const preprocess = ( } } + // Add optional fields + for (let field of [ + "Buses", + "Generators", + "Storage units", + "Price-sensitive loads", + "Transmission lines", + "Reserves", + "Contingencies", + ]) { + if (!result[field]) { + result[field] = {}; + } + } + const scenario = result as unknown as UnitCommitmentScenario; return [scenario, null]; }; diff --git a/web/src/core/Operations/storageOps.test.ts b/web/src/core/Operations/storageOps.test.ts new file mode 100644 index 0000000..bce18ef --- /dev/null +++ b/web/src/core/Operations/storageOps.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { TEST_DATA_1 } from "../Data/fixtures.test"; +import assert from "node:assert"; +import { + changeStorageUnitData, + createStorageUnit, + deleteStorageUnit, + renameStorageUnit, +} from "./storageOps"; +import { ValidationError } from "../Data/validate"; + +test("createStorageUnit", () => { + const [newScenario, err] = createStorageUnit(TEST_DATA_1); + assert(err === null); + assert.equal(Object.keys(newScenario["Storage units"]).length, 2); + assert("su2" in newScenario["Storage units"]); +}); + +test("renameStorageUnit", () => { + const [newScenario, err] = renameStorageUnit("su1", "su2", TEST_DATA_1); + assert(err === null); + assert.deepEqual( + newScenario["Storage units"]["su2"], + TEST_DATA_1["Storage units"]["su1"], + ); + assert.equal(Object.keys(newScenario["Storage units"]).length, 1); +}); + +test("changeStorageUnitData", () => { + let scenario = TEST_DATA_1; + let err: ValidationError | null; + [scenario, err] = changeStorageUnitData("su1", "Bus", "b3", scenario); + assert.equal(err, null); + [scenario, err] = changeStorageUnitData( + "su1", + "Minimum level (MWh)", + "99", + scenario, + ); + assert.equal(err, null); + [scenario, err] = changeStorageUnitData( + "su1", + "Maximum discharge rate (MW)", + "99", + scenario, + ); + assert.equal(err, null); + assert.deepEqual(scenario["Storage units"]["su1"], { + Bus: "b3", + "Minimum level (MWh)": 99.0, + "Maximum level (MWh)": 100.0, + "Charge cost ($/MW)": 2.0, + "Discharge cost ($/MW)": 1.0, + "Charge efficiency": 0.8, + "Discharge efficiency": 0.85, + "Loss factor": 0.01, + "Minimum charge rate (MW)": 5.0, + "Maximum charge rate (MW)": 10.0, + "Minimum discharge rate (MW)": 4.0, + "Maximum discharge rate (MW)": 99.0, + "Initial level (MWh)": 20.0, + "Last period minimum level (MWh)": 21.0, + "Last period maximum level (MWh)": 22.0, + }); +}); + +test("deleteStorageUnit", () => { + const newScenario = deleteStorageUnit("su1", TEST_DATA_1); + assert.equal(Object.keys(newScenario["Storage units"]).length, 0); +}); diff --git a/web/src/core/Operations/storageOps.ts b/web/src/core/Operations/storageOps.ts new file mode 100644 index 0000000..e200518 --- /dev/null +++ b/web/src/core/Operations/storageOps.ts @@ -0,0 +1,98 @@ +/* + * 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 { ValidationError } from "../Data/validate"; +import { StorageUnit, UnitCommitmentScenario } from "../Data/types"; +import { + assertBusesNotEmpty, + changeData, + generateUniqueName, + renameItemInObject, +} from "./commonOps"; +import { StorageUnitsColumnSpec } from "../../components/CaseBuilder/StorageUnits"; + +export const createStorageUnit = ( + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const err = assertBusesNotEmpty(scenario); + if (err) return [scenario, err]; + const busName = Object.keys(scenario.Buses)[0]!; + const name = generateUniqueName(scenario["Storage units"], "su"); + return [ + { + ...scenario, + "Storage units": { + ...scenario["Storage units"], + [name]: { + Bus: busName, + "Minimum level (MWh)": 0, + "Maximum level (MWh)": 1, + "Charge cost ($/MW)": 0.0, + "Discharge cost ($/MW)": 0.0, + "Charge efficiency": 1, + "Discharge efficiency": 1, + "Loss factor": 0, + "Minimum charge rate (MW)": 1, + "Maximum charge rate (MW)": 1, + "Minimum discharge rate (MW)": 1, + "Maximum discharge rate (MW)": 1, + "Initial level (MWh)": 0, + "Last period minimum level (MWh)": 0, + "Last period maximum level (MWh)": 1, + }, + }, + }, + null, + ]; +}; + +export const renameStorageUnit = ( + oldName: string, + newName: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [newObj, err] = renameItemInObject( + oldName, + newName, + scenario["Storage units"], + ); + if (err) return [scenario, err]; + return [{ ...scenario, "Storage units": newObj }, null]; +}; + +export const changeStorageUnitData = ( + name: string, + field: string, + newValueStr: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [newObj, err] = changeData( + field, + newValueStr, + scenario["Storage units"][name]!, + StorageUnitsColumnSpec, + scenario, + ); + if (err) return [scenario, err]; + return [ + { + ...scenario, + "Storage units": { + ...scenario["Storage units"], + [name]: newObj as StorageUnit, + }, + }, + null, + ]; +}; + +export const deleteStorageUnit = ( + name: string, + scenario: UnitCommitmentScenario, +): UnitCommitmentScenario => { + const { [name]: _, ...newContainer } = scenario["Storage units"]; + return { ...scenario, "Storage units": newContainer }; +};