From 9f560df4f5d12cb9b5104784e43903d35119c92c Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 10 Sep 2025 12:30:11 -0500 Subject: [PATCH] web: Add support for price-sensitive loads --- .../components/CaseBuilder/CaseBuilder.tsx | 6 + web/src/components/CaseBuilder/Psload.tsx | 175 ++++++++++++++++++ web/src/core/Data/fixtures.test.ts | 9 + web/src/core/Data/fixtures.tsx | 1 + web/src/core/Data/types.tsx | 9 + web/src/core/Operations/generatorOps.test.ts | 2 +- web/src/core/Operations/parameterOps.ts | 29 +++ web/src/core/Operations/psloadOps.test.ts | 60 ++++++ web/src/core/Operations/psloadOps.ts | 88 +++++++++ 9 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 web/src/components/CaseBuilder/Psload.tsx create mode 100644 web/src/core/Operations/psloadOps.test.ts create mode 100644 web/src/core/Operations/psloadOps.ts diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index 28c660b..868cf1f 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -21,6 +21,7 @@ import ThermalUnitsComponent from "./ThermalUnits"; import TransmissionLinesComponent from "./TransmissionLines"; import { UnitCommitmentScenario } from "../../core/Data/types"; import StorageComponent from "./StorageUnits"; +import PriceSensitiveLoadsComponent from "./Psload"; export interface CaseBuilderSectionProps { scenario: UnitCommitmentScenario; @@ -124,6 +125,11 @@ const CaseBuilder = () => { onDataChanged={onDataChanged} onError={setToastMessage} /> + { + const columns = generateTableColumns(scenario, PriceSensitiveLoadsColumnSpec); + const data = generateTableData( + scenario["Price-sensitive loads"], + PriceSensitiveLoadsColumnSpec, + scenario, + ); + return [data, columns]; +}; + +const PriceSensitiveLoadsComponent = (props: CaseBuilderSectionProps) => { + const fileUploadElem = useRef(null); + + const onDownload = () => { + const [data, columns] = generatePriceSensitiveLoadsData(props.scenario); + const csvContents = generateCsv(data, columns); + offerDownload(csvContents, "text/csv", "psloads.csv"); + }; + + const onUpload = () => { + fileUploadElem.current!.showFilePicker((csv: any) => { + // Parse provided CSV file + const [psloads, err] = parseCsv( + csv, + PriceSensitiveLoadsColumnSpec, + props.scenario, + ); + + // Handle validation errors + if (err) { + props.onError(err.message); + return; + } + + // Generate new scenario + props.onDataChanged({ + ...props.scenario, + "Price-sensitive loads": psloads, + }); + }); + }; + + const onAdd = () => { + const [newScenario, err] = createPriceSensitiveLoad(props.scenario); + if (err) { + props.onError(err.message); + return; + } + props.onDataChanged(newScenario); + }; + + const onDelete = (name: string): ValidationError | null => { + const newScenario = deletePriceSensitiveLoad(name, props.scenario); + props.onDataChanged(newScenario); + return null; + }; + + const onDataChanged = ( + name: string, + field: string, + newValue: string, + ): ValidationError | null => { + const [newScenario, err] = changePriceSensitiveLoadData( + 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] = renamePriceSensitiveLoad( + oldName, + newName, + props.scenario, + ); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + + return ( +
+ + + + + + generatePriceSensitiveLoadsData(props.scenario)} + /> + +
+ ); +}; + +export default PriceSensitiveLoadsComponent; diff --git a/web/src/core/Data/fixtures.test.ts b/web/src/core/Data/fixtures.test.ts index 350fe77..2a331b3 100644 --- a/web/src/core/Data/fixtures.test.ts +++ b/web/src/core/Data/fixtures.test.ts @@ -80,6 +80,13 @@ export const TEST_DATA_1: UnitCommitmentScenario = { "Last period maximum level (MWh)": 22.0, }, }, + "Price-sensitive loads": { + ps1: { + Bus: "b3", + "Revenue ($/MW)": 23.0, + "Demand (MW)": [50, 50, 50, 50, 50], + }, + }, }; export const TEST_DATA_2: UnitCommitmentScenario = { @@ -97,6 +104,7 @@ export const TEST_DATA_2: UnitCommitmentScenario = { Generators: {}, "Transmission lines": {}, "Storage units": {}, + "Price-sensitive loads": {}, }; export const TEST_DATA_BLANK: UnitCommitmentScenario = { @@ -110,6 +118,7 @@ export const TEST_DATA_BLANK: UnitCommitmentScenario = { Generators: {}, "Transmission lines": {}, "Storage units": {}, + "Price-sensitive loads": {}, }; test("fixtures", () => {}); diff --git a/web/src/core/Data/fixtures.tsx b/web/src/core/Data/fixtures.tsx index 3b2cf73..9b6a870 100644 --- a/web/src/core/Data/fixtures.tsx +++ b/web/src/core/Data/fixtures.tsx @@ -21,4 +21,5 @@ export const BLANK_SCENARIO: UnitCommitmentScenario = { Generators: {}, "Transmission lines": {}, "Storage units": {}, + "Price-sensitive loads": {}, }; diff --git a/web/src/core/Data/types.tsx b/web/src/core/Data/types.tsx index c49433f..3d11661 100644 --- a/web/src/core/Data/types.tsx +++ b/web/src/core/Data/types.tsx @@ -63,6 +63,12 @@ export interface StorageUnit { "Last period maximum level (MWh)": number; } +export interface PriceSensitiveLoad { + Bus: string; + "Revenue ($/MW)": number; + "Demand (MW)": number[]; +} + export interface UnitCommitmentScenario { Parameters: { Version: string; @@ -78,6 +84,9 @@ export interface UnitCommitmentScenario { "Storage units": { [name: string]: StorageUnit; }; + "Price-sensitive loads": { + [name: string]: PriceSensitiveLoad; + }; } const getTypedGenerators = ( diff --git a/web/src/core/Operations/generatorOps.test.ts b/web/src/core/Operations/generatorOps.test.ts index ecb1f12..fd5a44b 100644 --- a/web/src/core/Operations/generatorOps.test.ts +++ b/web/src/core/Operations/generatorOps.test.ts @@ -33,7 +33,7 @@ test("createThermalUnit", () => { test("createProfiledUnit with blank file", () => { const [, err] = createProfiledUnit(TEST_DATA_BLANK); assert(err !== null); - assert.equal(err.message, "Profiled unit requires an existing bus."); + assert.equal(err.message, "This component requires an existing bus."); }); test("changeProfiledUnitData", () => { diff --git a/web/src/core/Operations/parameterOps.ts b/web/src/core/Operations/parameterOps.ts index 175b626..7a14890 100644 --- a/web/src/core/Operations/parameterOps.ts +++ b/web/src/core/Operations/parameterOps.ts @@ -35,6 +35,9 @@ export const changeTimeHorizon = ( generator["Maximum power (MW)"] = generator["Maximum power (MW)"].slice(0, newT); } }); + Object.values(newScenario["Price-sensitive loads"]).forEach((psLoad) => { + psLoad["Demand (MW)"] = psLoad["Demand (MW)"].slice(0, newT); + }); } else { const padding = Array(newT - oldT).fill(0); Object.values(newScenario.Buses).forEach((bus) => { @@ -46,6 +49,9 @@ export const changeTimeHorizon = ( generator["Maximum power (MW)"] = generator["Maximum power (MW)"].concat(padding); } }); + Object.values(newScenario["Price-sensitive loads"]).forEach((psLoad) => { + psLoad["Demand (MW)"] = psLoad["Demand (MW)"].concat(padding); + }); } return [newScenario, null]; }; @@ -156,6 +162,28 @@ export const changeTimeStep = ( } } + const newPriceSensitiveLoads: { [name: string]: any } = {}; + for (const psLoadName in scenario["Price-sensitive loads"]) { + const psLoad = scenario["Price-sensitive loads"][psLoadName]!; + + // Build data_y for demand + const demand = psLoad["Demand (MW)"]; + const demandData_y = Array(oldT + 1).fill(0); + for (let i = 0; i < oldT; i++) demandData_y[i] = demand[i]; + demandData_y[oldT] = demandData_y[0]; + + // Run interpolation for demand + const newDemand = Array(newT).fill(0); + for (let i = 0; i < newT; i++) { + newDemand[i] = evaluatePwlFunction(data_x, demandData_y, newTimeStep * i); + } + + newPriceSensitiveLoads[psLoadName] = { + ...psLoad, + "Demand (MW)": newDemand, + }; + } + return [ { ...scenario, @@ -165,6 +193,7 @@ export const changeTimeStep = ( }, Buses: newBuses, Generators: newGenerators, + "Price-sensitive loads": newPriceSensitiveLoads, }, null, ]; diff --git a/web/src/core/Operations/psloadOps.test.ts b/web/src/core/Operations/psloadOps.test.ts new file mode 100644 index 0000000..8efd344 --- /dev/null +++ b/web/src/core/Operations/psloadOps.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { + changePriceSensitiveLoadData, + createPriceSensitiveLoad, + deletePriceSensitiveLoad, + renamePriceSensitiveLoad, +} from "./psloadOps"; +import { ValidationError } from "../Data/validate"; + +test("createPriceSensitiveLoad", () => { + const [newScenario, err] = createPriceSensitiveLoad(TEST_DATA_1); + assert(err === null); + assert.equal(Object.keys(newScenario["Price-sensitive loads"]).length, 2); + assert("ps2" in newScenario["Price-sensitive loads"]); +}); + +test("renamePriceSensitiveLoad", () => { + const [newScenario, err] = renamePriceSensitiveLoad( + "ps1", + "ps2", + TEST_DATA_1, + ); + assert(err === null); + assert.deepEqual( + newScenario["Price-sensitive loads"]["ps2"], + TEST_DATA_1["Price-sensitive loads"]["ps1"], + ); + assert.equal(Object.keys(newScenario["Price-sensitive loads"]).length, 1); +}); + +test("changePriceSensitiveLoadData", () => { + let scenario = TEST_DATA_1; + let err: ValidationError | null; + [scenario, err] = changePriceSensitiveLoadData("ps1", "Bus", "b3", scenario); + assert.equal(err, null); + [scenario, err] = changePriceSensitiveLoadData( + "ps1", + "Demand (MW) 00:00", + "99", + scenario, + ); + assert.equal(err, null); + assert.deepEqual(scenario["Price-sensitive loads"]["ps1"], { + Bus: "b3", + "Revenue ($/MW)": 23, + "Demand (MW)": [99, 50, 50, 50, 50], + }); +}); + +test("deletePriceSensitiveLoad", () => { + const newScenario = deletePriceSensitiveLoad("ps1", TEST_DATA_1); + assert.equal(Object.keys(newScenario["Price-sensitive loads"]).length, 0); +}); diff --git a/web/src/core/Operations/psloadOps.ts b/web/src/core/Operations/psloadOps.ts new file mode 100644 index 0000000..62bc170 --- /dev/null +++ b/web/src/core/Operations/psloadOps.ts @@ -0,0 +1,88 @@ +/* + * 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 { PriceSensitiveLoad, UnitCommitmentScenario } from "../Data/types"; +import { + assertBusesNotEmpty, + changeData, + generateUniqueName, + renameItemInObject, +} from "./commonOps"; +import { PriceSensitiveLoadsColumnSpec } from "../../components/CaseBuilder/Psload"; +import { generateTimeslots } from "../../components/Common/Forms/DataTable"; + +export const createPriceSensitiveLoad = ( + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const err = assertBusesNotEmpty(scenario); + if (err) return [scenario, err]; + const busName = Object.keys(scenario.Buses)[0]!; + const timeslots = generateTimeslots(scenario); + const name = generateUniqueName(scenario["Price-sensitive loads"], "ps"); + return [ + { + ...scenario, + "Price-sensitive loads": { + ...scenario["Price-sensitive loads"], + [name]: { + Bus: busName, + "Revenue ($/MW)": 0, + "Demand (MW)": Array(timeslots.length).fill(0), + }, + }, + }, + null, + ]; +}; + +export const renamePriceSensitiveLoad = ( + oldName: string, + newName: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [newObj, err] = renameItemInObject( + oldName, + newName, + scenario["Price-sensitive loads"], + ); + if (err) return [scenario, err]; + return [{ ...scenario, "Price-sensitive loads": newObj }, null]; +}; + +export const changePriceSensitiveLoadData = ( + name: string, + field: string, + newValueStr: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [newObj, err] = changeData( + field, + newValueStr, + scenario["Price-sensitive loads"][name]!, + PriceSensitiveLoadsColumnSpec, + scenario, + ); + if (err) return [scenario, err]; + return [ + { + ...scenario, + "Price-sensitive loads": { + ...scenario["Price-sensitive loads"], + [name]: newObj as PriceSensitiveLoad, + }, + }, + null, + ]; +}; + +export const deletePriceSensitiveLoad = ( + name: string, + scenario: UnitCommitmentScenario, +): UnitCommitmentScenario => { + const { [name]: _, ...newContainer } = scenario["Price-sensitive loads"]; + return { ...scenario, "Price-sensitive loads": newContainer }; +};