From 1b37af82e394a75f8cf998e8b543311bf5bbaf0d Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 24 Jun 2025 10:39:06 -0500 Subject: [PATCH] web: ProfiledUnits: Add data change and rename functionality --- .../ProfiledUnits/ProfiledUnits.tsx | 50 ++++++-- web/src/components/Common/Forms/DataTable.tsx | 6 +- web/src/core/Operations/busOps.test.ts | 2 +- web/src/core/Operations/busOps.ts | 57 ++++----- web/src/core/Operations/commonOps.ts | 118 ++++++++++++++++++ web/src/core/Operations/generatorOps.test.ts | 48 ++++++- web/src/core/Operations/generatorOps.ts | 35 +++++- 7 files changed, 270 insertions(+), 46 deletions(-) diff --git a/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx b/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx index de885af..fa18bb5 100644 --- a/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx +++ b/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx @@ -24,8 +24,10 @@ import { offerDownload } from "../../Common/io"; import FileUploadElement from "../../Common/Buttons/FileUploadElement"; import { useRef } from "react"; import { + changeProfiledUnitData, createProfiledUnit, deleteGenerator, + renameGenerator, } from "../../../core/Operations/generatorOps"; import { ValidationError } from "../../../core/Validation/validate"; @@ -35,7 +37,7 @@ interface ProfiledUnitsProps { onError: (msg: string) => void; } -const ProfiledUnitsColumnSpec: ColumnSpec[] = [ +export const ProfiledUnitsColumnSpec: ColumnSpec[] = [ { title: "Name", type: "string", @@ -43,7 +45,7 @@ const ProfiledUnitsColumnSpec: ColumnSpec[] = [ }, { title: "Bus", - type: "string", + type: "busRef", width: 150, }, { @@ -118,6 +120,42 @@ const ProfiledUnitsComponent = (props: ProfiledUnitsProps) => { return null; }; + const onDataChanged = ( + name: string, + field: string, + newValue: string, + ): ValidationError | null => { + const [newScenario, err] = changeProfiledUnitData( + 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] = renameGenerator( + oldName, + newName, + props.scenario, + ); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + return (
@@ -131,12 +169,8 @@ const ProfiledUnitsComponent = (props: ProfiledUnitsProps) => { { - return null; - }} - onDataChanged={() => { - return null; - }} + onRowRenamed={onRename} + onDataChanged={onDataChanged} generateData={() => generateProfiledUnitsData(props.scenario)} /> diff --git a/web/src/components/Common/Forms/DataTable.tsx b/web/src/components/Common/Forms/DataTable.tsx index 5134aaf..541b8cd 100644 --- a/web/src/components/Common/Forms/DataTable.tsx +++ b/web/src/components/Common/Forms/DataTable.tsx @@ -16,7 +16,7 @@ import Papa from "papaparse"; export interface ColumnSpec { title: string; - type: "string" | "number" | "number[]"; + type: "string" | "number" | "number[]" | "busRef"; width: number; } @@ -29,6 +29,7 @@ export const generateTableColumns = ( colSpecs.forEach((spec) => { switch (spec.type) { case "string": + case "busRef": columns.push({ ...columnsCommonAttrs, title: spec.title, @@ -62,7 +63,7 @@ export const generateTableColumns = ( }); break; default: - console.error(`Unknown type: ${spec.type}`); + throw Error(`Unknown type: ${spec.type}`); } }); return columns; @@ -88,6 +89,7 @@ export const generateTableData = ( switch (spec.type) { case "string": case "number": + case "busRef": entry[spec.title] = entryData[spec.title]; break; case "number[]": diff --git a/web/src/core/Operations/busOps.test.ts b/web/src/core/Operations/busOps.test.ts index 8a6c0ae..efb2008 100644 --- a/web/src/core/Operations/busOps.test.ts +++ b/web/src/core/Operations/busOps.test.ts @@ -35,7 +35,7 @@ test("changeBusData", () => { test("changeBusData with invalid numbers", () => { let [, err] = changeBusData("b1", "Load (MW) 00:00", "xx", TEST_DATA_1); assert(err !== null); - assert.equal(err.message, "Invalid value: xx"); + assert.equal(err.message, '"xx" is not a valid number'); }); test("deleteBus", () => { diff --git a/web/src/core/Operations/busOps.ts b/web/src/core/Operations/busOps.ts index 175eba6..d2ebb3b 100644 --- a/web/src/core/Operations/busOps.ts +++ b/web/src/core/Operations/busOps.ts @@ -4,10 +4,15 @@ * Released under the modified BSD license. See COPYING.md for more details. */ -import { UnitCommitmentScenario } from "../fixtures"; +import { Buses, UnitCommitmentScenario } from "../fixtures"; import { ValidationError } from "../Validation/validate"; import { generateTimeslots } from "../../components/Common/Forms/DataTable"; -import { generateUniqueName, renameItemInObject } from "./commonOps"; +import { + changeData, + generateUniqueName, + renameItemInObject, +} from "./commonOps"; +import { BusesColumnSpec } from "../../components/CaseBuilder/Buses/Buses"; export const createBus = (scenario: UnitCommitmentScenario) => { const name = generateUniqueName(scenario.Buses, "b"); @@ -29,36 +34,24 @@ export const changeBusData = ( newValueStr: string, scenario: UnitCommitmentScenario, ): [UnitCommitmentScenario, ValidationError | null] => { - // Load (MW) - const match = field.match(/Load \(MW\) (\d+):(\d+)/); - if (match) { - const newValueFloat = parseFloat(newValueStr); - if (isNaN(newValueFloat)) { - return [scenario, { message: `Invalid value: ${newValueStr}` }]; - } - - // Convert HH:MM to offset - const hours = parseInt(match[1]!, 10); - const min = parseInt(match[2]!, 10); - const idx = (hours * 60 + min) / scenario.Parameters["Time step (min)"]; - - const newLoad = [...scenario.Buses[bus]!["Load (MW)"]]; - newLoad[idx] = newValueFloat; - return [ - { - ...scenario, - Buses: { - ...scenario.Buses, - [bus]: { - "Load (MW)": newLoad, - }, - }, - }, - null, - ]; - } - - throw Error(`Unknown field: ${field}`); + const [newBus, err] = changeData( + field, + newValueStr, + scenario.Buses[bus]!, + BusesColumnSpec, + scenario, + ); + if (err) return [scenario, err]; + return [ + { + ...scenario, + Buses: { + ...scenario.Buses, + [bus]: newBus, + } as Buses, + }, + null, + ]; }; export const deleteBus = (bus: string, scenario: UnitCommitmentScenario) => { diff --git a/web/src/core/Operations/commonOps.ts b/web/src/core/Operations/commonOps.ts index 804800e..eb580da 100644 --- a/web/src/core/Operations/commonOps.ts +++ b/web/src/core/Operations/commonOps.ts @@ -5,6 +5,8 @@ */ import { ValidationError } from "../Validation/validate"; +import { UnitCommitmentScenario } from "../fixtures"; +import { ColumnSpec } from "../../components/Common/Forms/DataTable"; export const renameItemInObject = ( oldName: string, @@ -37,3 +39,119 @@ export const generateUniqueName = (container: any, prefix: string): string => { } return name; }; + +const parseNumber = (valueStr: string): [number, ValidationError | null] => { + const valueFloat = parseFloat(valueStr); + if (isNaN(valueFloat)) { + return [0, { message: `"${valueStr}" is not a valid number` }]; + } else { + return [valueFloat, null]; + } +}; + +export const changeStringData = ( + field: string, + newValue: string, + container: { [key: string]: any }, +): [{ [key: string]: any }, ValidationError | null] => { + return [ + { + ...container, + [field]: newValue, + }, + null, + ]; +}; + +export const changeBusRefData = ( + field: string, + newValue: string, + container: { [key: string]: any }, + scenario: UnitCommitmentScenario, +): [{ [key: string]: any }, ValidationError | null] => { + if (!(newValue in scenario.Buses)) { + return [scenario, { message: `Bus "${newValue}" does not exist` }]; + } + return changeStringData(field, newValue, container); +}; + +export const changeNumberData = ( + field: string, + newValueStr: string, + container: { [key: string]: any }, +): [{ [key: string]: any }, ValidationError | null] => { + // Parse value + const [newValueFloat, err] = parseNumber(newValueStr); + if (err) return [container, err]; + + // Build the new object + return [ + { + ...container, + [field]: newValueFloat, + }, + null, + ]; +}; + +export const changeNumberVecData = ( + field: string, + time: string, + newValueStr: string, + container: { [key: string]: any }, + scenario: UnitCommitmentScenario, +): [{ [key: string]: any }, ValidationError | null] => { + // Parse value + const [newValueFloat, err] = parseNumber(newValueStr); + if (err) return [container, err]; + + // Convert HH:MM to offset + const hours = parseInt(time.split(":")[0]!, 10); + const min = parseInt(time.split(":")[1]!, 10); + const idx = (hours * 60 + min) / scenario.Parameters["Time step (min)"]; + + // Build the new vector + const newVec = [...container[field]]; + newVec[idx] = newValueFloat; + return [ + { + ...container, + [field]: newVec, + }, + null, + ]; +}; + +export const changeData = ( + field: string, + newValueStr: string, + container: { [key: string]: any }, + colSpecs: ColumnSpec[], + scenario: UnitCommitmentScenario, +): [{ [key: string]: any }, ValidationError | null] => { + const match = field.match(/^([^0-9]+)(\d+:\d+)?$/); + const fieldName = match![1]!.trim(); + const fieldTime = match![2]; + for (const spec of colSpecs) { + if (spec.title !== fieldName) continue; + switch (spec.type) { + case "string": + return changeStringData(fieldName, newValueStr, container); + case "busRef": + return changeBusRefData(fieldName, newValueStr, container, scenario); + case "number": + return changeNumberData(fieldName, newValueStr, container); + case "number[]": + return changeNumberVecData( + fieldName, + fieldTime!, + newValueStr, + container, + scenario, + ); + default: + throw Error(`Unknown type: ${spec.type}`); + } + } + throw Error(`Unknown field: ${fieldName}`); +}; diff --git a/web/src/core/Operations/generatorOps.test.ts b/web/src/core/Operations/generatorOps.test.ts index 1b8a3e7..4302ce3 100644 --- a/web/src/core/Operations/generatorOps.test.ts +++ b/web/src/core/Operations/generatorOps.test.ts @@ -7,6 +7,7 @@ import { TEST_DATA_1, TEST_DATA_BLANK } from "../fixtures.test"; import assert from "node:assert"; import { + changeProfiledUnitData, createProfiledUnit, deleteGenerator, renameGenerator, @@ -41,11 +42,56 @@ test("createProfiledUnit", () => { }); test("createProfiledUnit with blank file", () => { - const [_, err] = createProfiledUnit(TEST_DATA_BLANK); + const [, err] = createProfiledUnit(TEST_DATA_BLANK); assert(err !== null); assert.equal(err.message, "Profiled unit requires an existing bus."); }); +test("changeProfiledUnitData", () => { + let scenario = TEST_DATA_1; + let err = null; + [scenario, err] = changeProfiledUnitData( + "pu1", + "Cost ($/MW)", + "99", + scenario, + ); + assert.equal(err, null); + [scenario, err] = changeProfiledUnitData( + "pu1", + "Maximum power (MW) 03:00", + "99", + scenario, + ); + assert.equal(err, null); + [scenario, err] = changeProfiledUnitData("pu2", "Bus", "b3", scenario); + assert.equal(err, null); + assert.deepEqual(scenario.Generators, { + pu1: { + Bus: "b1", + Type: "Profiled", + "Cost ($/MW)": 99, + "Maximum power (MW)": [10, 12, 13, 99, 20], + "Minimum power (MW)": [0, 0, 0, 0, 0], + }, + pu2: { + Bus: "b3", + Type: "Profiled", + "Cost ($/MW)": 120, + "Maximum power (MW)": [50, 50, 50, 50, 50], + "Minimum power (MW)": [0, 0, 0, 0, 0], + }, + }); +}); + +test("changeProfiledUnitData with invalid bus", () => { + let scenario = TEST_DATA_1; + let err = null; + [scenario, err] = changeProfiledUnitData("pu1", "Bus", "b99", scenario); + assert(err !== null); + assert.equal(err.message, 'Bus "b99" does not exist'); +}); + test("deleteGenerator", () => { const newScenario = deleteGenerator("pu1", TEST_DATA_1); assert.deepEqual(newScenario.Generators, { diff --git a/web/src/core/Operations/generatorOps.ts b/web/src/core/Operations/generatorOps.ts index ecac5a4..e99535a 100644 --- a/web/src/core/Operations/generatorOps.ts +++ b/web/src/core/Operations/generatorOps.ts @@ -4,10 +4,15 @@ * Released under the modified BSD license. See COPYING.md for more details. */ -import { UnitCommitmentScenario } from "../fixtures"; +import { Generators, UnitCommitmentScenario } from "../fixtures"; import { generateTimeslots } from "../../components/Common/Forms/DataTable"; import { ValidationError } from "../Validation/validate"; -import { generateUniqueName, renameItemInObject } from "./commonOps"; +import { + changeData, + generateUniqueName, + renameItemInObject, +} from "./commonOps"; +import { ProfiledUnitsColumnSpec } from "../../components/CaseBuilder/ProfiledUnits/ProfiledUnits"; export const createProfiledUnit = ( scenario: UnitCommitmentScenario, @@ -36,6 +41,32 @@ export const createProfiledUnit = ( ]; }; +export const changeProfiledUnitData = ( + generator: string, + field: string, + newValueStr: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [newGen, err] = changeData( + field, + newValueStr, + scenario.Generators[generator]!, + ProfiledUnitsColumnSpec, + scenario, + ); + if (err) return [scenario, err]; + return [ + { + ...scenario, + Generators: { + ...scenario.Generators, + [generator]: newGen, + } as Generators, + }, + null, + ]; +}; + export const deleteGenerator = ( name: string, scenario: UnitCommitmentScenario,