diff --git a/web/src/components/CaseBuilder/Buses.test.ts b/web/src/components/CaseBuilder/Buses.test.ts new file mode 100644 index 0000000..bc74a3b --- /dev/null +++ b/web/src/components/CaseBuilder/Buses.test.ts @@ -0,0 +1,48 @@ +/* + * 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 assert from "node:assert"; +import { BusesColumnSpec, generateBusesData } from "./Buses"; +import { generateCsv, parseCsv } from "../Common/Forms/DataTable"; +import { TEST_DATA_1 } from "../../core/fixtures.test"; + +test("generate CSV", () => { + const [data, columns] = generateBusesData(TEST_DATA_1); + const actualCsv = generateCsv(data, columns); + const expectedCsv = + "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + + "b1,35.79534,34.38835,33.45083,32.89729,33.25044\n" + + "b2,14.03739,13.48563,13.11797,12.9009,13.03939\n" + + "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; + assert.strictEqual(actualCsv, expectedCsv); +}); + +test("parse CSV", () => { + const csvContents = + "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + + "b1,0,1,2,3,4\n" + + "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; + const [newBuses, err] = parseCsv(csvContents, BusesColumnSpec, TEST_DATA_1); + assert(err === null); + assert.deepEqual(newBuses, { + b1: { + "Load (MW)": [0, 1, 2, 3, 4], + }, + b3: { + "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268], + }, + }); +}); + +test("parse CSV with duplicated names", () => { + const csvContents = + "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + + "b1,0,0,0,0,0\n" + + "b1,0,0,0,0,0"; + const [, err] = parseCsv(csvContents, BusesColumnSpec, TEST_DATA_1); + assert(err !== null); + assert.equal(err.message, `Name "b1" is duplicated (row 2)`); +}); diff --git a/web/src/components/CaseBuilder/ThermalUnits.test.ts b/web/src/components/CaseBuilder/ThermalUnits.test.ts new file mode 100644 index 0000000..0d2aa97 --- /dev/null +++ b/web/src/components/CaseBuilder/ThermalUnits.test.ts @@ -0,0 +1,209 @@ +/* + * 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 { + floatFormatter, + generateCsv, + generateTableColumns, + generateTableData, +} from "../Common/Forms/DataTable"; +import { TEST_DATA_1 } from "../../core/fixtures.test"; +import { + generateThermalUnitsData, + parseThermalUnitsCsv, + ThermalUnitsColumnSpec, +} from "./ThermalUnits"; +import assert from "node:assert"; +import { + getProfiledGenerators, + getThermalGenerators, +} from "../../core/fixtures"; + +test("generateTableColumns", () => { + const columns = generateTableColumns(TEST_DATA_1, ThermalUnitsColumnSpec); + assert.equal(columns[2]!["columns"]!.length, 10); + assert.deepEqual(columns[2]!["columns"]![0], { + editor: "input", + editorParams: { + selectContents: true, + }, + field: "Production cost curve (MW) 1", + formatter: floatFormatter, + headerHozAlign: "left", + headerSort: false, + headerWordWrap: true, + hozAlign: "left", + minWidth: 60, + resizable: false, + title: "1", + }); +}); + +test("generateTableData", () => { + const data = generateTableData( + getThermalGenerators(TEST_DATA_1), + ThermalUnitsColumnSpec, + TEST_DATA_1, + ); + assert.deepEqual(data[0], { + Name: "g1", + Bus: "b1", + "Initial power (MW)": 115, + "Initial status (h)": 12, + "Minimum downtime (h)": 4, + "Minimum uptime (h)": 4, + "Ramp down limit (MW)": 232.68, + "Ramp up limit (MW)": 232.68, + "Shutdown limit (MW)": 232.68, + "Startup limit (MW)": 232.68, + "Production cost curve ($) 1": 1400, + "Production cost curve ($) 2": 1600, + "Production cost curve ($) 3": 2200, + "Production cost curve ($) 4": 2400, + "Production cost curve ($) 5": "", + "Production cost curve ($) 6": "", + "Production cost curve ($) 7": "", + "Production cost curve ($) 8": "", + "Production cost curve ($) 9": "", + "Production cost curve ($) 10": "", + "Production cost curve (MW) 1": 100, + "Production cost curve (MW) 2": 110, + "Production cost curve (MW) 3": 130, + "Production cost curve (MW) 4": 135, + "Production cost curve (MW) 5": "", + "Production cost curve (MW) 6": "", + "Production cost curve (MW) 7": "", + "Production cost curve (MW) 8": "", + "Production cost curve (MW) 9": "", + "Production cost curve (MW) 10": "", + "Startup costs ($) 1": 300, + "Startup costs ($) 2": 400, + "Startup costs ($) 3": "", + "Startup costs ($) 4": "", + "Startup costs ($) 5": "", + "Startup delays (h) 1": 1, + "Startup delays (h) 2": 4, + "Startup delays (h) 3": "", + "Startup delays (h) 4": "", + "Startup delays (h) 5": "", + "Must run?": false, + }); +}); + +const expectedCsvContents = + "Name,Bus," + + "Production cost curve (MW) 1," + + "Production cost curve (MW) 2," + + "Production cost curve (MW) 3," + + "Production cost curve (MW) 4," + + "Production cost curve (MW) 5," + + "Production cost curve (MW) 6," + + "Production cost curve (MW) 7," + + "Production cost curve (MW) 8," + + "Production cost curve (MW) 9," + + "Production cost curve (MW) 10," + + "Production cost curve ($) 1," + + "Production cost curve ($) 2," + + "Production cost curve ($) 3," + + "Production cost curve ($) 4," + + "Production cost curve ($) 5," + + "Production cost curve ($) 6," + + "Production cost curve ($) 7," + + "Production cost curve ($) 8," + + "Production cost curve ($) 9," + + "Production cost curve ($) 10," + + "Startup costs ($) 1," + + "Startup costs ($) 2," + + "Startup costs ($) 3," + + "Startup costs ($) 4," + + "Startup costs ($) 5," + + "Startup delays (h) 1," + + "Startup delays (h) 2," + + "Startup delays (h) 3," + + "Startup delays (h) 4," + + "Startup delays (h) 5," + + "Minimum uptime (h),Minimum downtime (h),Ramp up limit (MW)," + + "Ramp down limit (MW),Startup limit (MW),Shutdown limit (MW)," + + "Initial status (h),Initial power (MW),Must run?\n" + + "g1,b1,100,110,130,135,,,,,,,1400,1600,2200,2400,,,,,,,300,400,,,,1,4,,,,4,4,232.68,232.68,232.68,232.68,12,115,false"; + +const invalidCsv = + "Name,Bus," + + "Production cost curve (MW) 1," + + "Production cost curve (MW) 2," + + "Production cost curve (MW) 3," + + "Production cost curve (MW) 4," + + "Production cost curve (MW) 5," + + "Production cost curve (MW) 6," + + "Production cost curve (MW) 7," + + "Production cost curve (MW) 8," + + "Production cost curve (MW) 9," + + "Production cost curve (MW) 10," + + "Production cost curve ($) 1," + + "Production cost curve ($) 2," + + "Production cost curve ($) 3," + + "Production cost curve ($) 4," + + "Production cost curve ($) 5," + + "Production cost curve ($) 6," + + "Production cost curve ($) 7," + + "Production cost curve ($) 8," + + "Production cost curve ($) 9," + + "Production cost curve ($) 10," + + "Startup costs ($) 1," + + "Startup costs ($) 2," + + "Startup costs ($) 3," + + "Startup costs ($) 4," + + "Startup costs ($) 5," + + "Startup delays (h) 1," + + "Startup delays (h) 2," + + "Startup delays (h) 3," + + "Startup delays (h) 4," + + "Startup delays (h) 5," + + "Minimum uptime (h),Minimum downtime (h),Ramp up limit (MW)," + + "Ramp down limit (MW),Startup limit (MW),Shutdown limit (MW)," + + "Initial status (h),Initial power (MW),Must run?\n" + + "g1,b1,100,110,130,x,,,,,,,1400,1600,2200,2400,,,,,,,300,400,,,,1,4,,,,4,4,232.68,232.68,232.68,232.68,12,115,false"; + +test("generateCSV", () => { + const [data, columns] = generateThermalUnitsData(TEST_DATA_1); + const actualCsvContents = generateCsv(data, columns); + assert.equal(actualCsvContents, expectedCsvContents); +}); + +test("parseCSV", () => { + const [scenario, err] = parseThermalUnitsCsv( + expectedCsvContents, + TEST_DATA_1, + ); + assert(!err); + const thermalGens = getThermalGenerators(scenario); + const profGens = getProfiledGenerators(scenario); + assert.equal(Object.keys(thermalGens).length, 1); + assert.equal(Object.keys(profGens).length, 2); + assert.deepEqual(thermalGens["g1"], { + Bus: "b1", + Type: "Thermal", + "Production cost curve (MW)": [100.0, 110.0, 130.0, 135.0], + "Production cost curve ($)": [1400.0, 1600.0, 2200.0, 2400.0], + "Startup costs ($)": [300.0, 400.0], + "Startup delays (h)": [1, 4], + "Ramp up limit (MW)": 232.68, + "Ramp down limit (MW)": 232.68, + "Startup limit (MW)": 232.68, + "Shutdown limit (MW)": 232.68, + "Minimum downtime (h)": 4, + "Minimum uptime (h)": 4, + "Initial status (h)": 12, + "Initial power (MW)": 115, + "Must run?": false, + }); +}); + +test("parseCSV with invalid number[T]", () => { + const [, err] = parseThermalUnitsCsv(invalidCsv, TEST_DATA_1); + assert(err); + assert.equal(err.message, '"x" is not a valid number (row 1)'); +}); diff --git a/web/src/components/CaseBuilder/ThermalUnits.tsx b/web/src/components/CaseBuilder/ThermalUnits.tsx index 94fdd24..77dcc10 100644 --- a/web/src/components/CaseBuilder/ThermalUnits.tsx +++ b/web/src/components/CaseBuilder/ThermalUnits.tsx @@ -9,6 +9,7 @@ import DataTable, { generateCsv, generateTableColumns, generateTableData, + parseCsv, } from "../Common/Forms/DataTable"; import { CaseBuilderSectionProps } from "./CaseBuilder"; import { useRef } from "react"; @@ -22,6 +23,7 @@ import { faUpload, } from "@fortawesome/free-solid-svg-icons"; import { + getProfiledGenerators, getThermalGenerators, UnitCommitmentScenario, } from "../../core/fixtures"; @@ -115,7 +117,7 @@ export const ThermalUnitsColumnSpec: ColumnSpec[] = [ }, ]; -const generateThermalUnitsData = ( +export const generateThermalUnitsData = ( scenario: UnitCommitmentScenario, ): [any[], ColumnDefinition[]] => { const columns = generateTableColumns(scenario, ThermalUnitsColumnSpec); @@ -127,6 +129,31 @@ const generateThermalUnitsData = ( return [data, columns]; }; +export const parseThermalUnitsCsv = ( + csvContents: string, + scenario: UnitCommitmentScenario, +): [UnitCommitmentScenario, ValidationError | null] => { + const [thermalGens, err] = parseCsv( + csvContents, + ThermalUnitsColumnSpec, + scenario, + ); + if (err) return [scenario, err]; + + // Process imported generators + for (const gen in thermalGens) { + thermalGens[gen]["Type"] = "Thermal"; + } + + // Merge with existing data + const profGens = getProfiledGenerators(scenario); + const newScenario = { + ...scenario, + Generators: { ...thermalGens, ...profGens }, + }; + return [newScenario, null]; +}; + const ThermalUnitsComponent = (props: CaseBuilderSectionProps) => { const fileUploadElem = useRef(null); @@ -137,26 +164,14 @@ const ThermalUnitsComponent = (props: CaseBuilderSectionProps) => { }; const onUpload = () => { - // fileUploadElem.current!.showFilePicker((csvContents: any) => { - // const [newGenerators, err] = parseCsv( - // csvContents, - // ThermalUnitsColumnSpec, - // props.scenario, - // ); - // if (err) { - // props.onError(err.message); - // return; - // } - // for (const gen in newGenerators) { - // newGenerators[gen]["Type"] = "Thermal"; - // } - // - // const newScenario = { - // ...props.scenario, - // Generators: newGenerators, - // }; - // props.onDataChanged(newScenario); - // }); + fileUploadElem.current!.showFilePicker((csv: any) => { + const [newScenario, err] = parseThermalUnitsCsv(csv, props.scenario); + if (err) { + props.onError(err.message); + return; + } + props.onDataChanged(newScenario); + }); }; const onAdd = () => { diff --git a/web/src/components/Common/Forms/DataTable.test.ts b/web/src/components/Common/Forms/DataTable.test.ts deleted file mode 100644 index ccc92b7..0000000 --- a/web/src/components/Common/Forms/DataTable.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * 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 assert from "node:assert"; -import { BusesColumnSpec, generateBusesData } from "../../CaseBuilder/Buses"; -import { - floatFormatter, - generateCsv, - generateTableColumns, - generateTableData, - parseCsv, -} from "./DataTable"; -import { TEST_DATA_1 } from "../../../core/fixtures.test"; -import { ThermalUnitsColumnSpec } from "../../CaseBuilder/ThermalUnits"; -import { getThermalGenerators } from "../../../core/fixtures"; - -test("generateTableColumns (ThermalUnits)", () => { - const columns = generateTableColumns(TEST_DATA_1, ThermalUnitsColumnSpec); - assert.equal(columns[2]!["columns"]!.length, 10); - assert.deepEqual(columns[2]!["columns"]![0], { - editor: "input", - editorParams: { - selectContents: true, - }, - field: "Production cost curve (MW) 1", - formatter: floatFormatter, - headerHozAlign: "left", - headerSort: false, - headerWordWrap: true, - hozAlign: "left", - minWidth: 60, - resizable: false, - title: "1", - }); -}); - -test("generateTableData (ThermalUnits)", () => { - const data = generateTableData( - getThermalGenerators(TEST_DATA_1), - ThermalUnitsColumnSpec, - TEST_DATA_1, - ); - assert.deepEqual(data[0], { - Name: "g1", - Bus: "b1", - "Initial power (MW)": 115, - "Initial status (h)": 12, - "Minimum downtime (h)": 4, - "Minimum uptime (h)": 4, - "Ramp down limit (MW)": 232.68, - "Ramp up limit (MW)": 232.68, - "Shutdown limit (MW)": 232.68, - "Startup limit (MW)": 232.68, - "Production cost curve ($) 1": 1400, - "Production cost curve ($) 2": 1600, - "Production cost curve ($) 3": 2200, - "Production cost curve ($) 4": 2400, - "Production cost curve ($) 5": "", - "Production cost curve ($) 6": "", - "Production cost curve ($) 7": "", - "Production cost curve ($) 8": "", - "Production cost curve ($) 9": "", - "Production cost curve ($) 10": "", - "Production cost curve (MW) 1": 100, - "Production cost curve (MW) 2": 110, - "Production cost curve (MW) 3": 130, - "Production cost curve (MW) 4": 135, - "Production cost curve (MW) 5": "", - "Production cost curve (MW) 6": "", - "Production cost curve (MW) 7": "", - "Production cost curve (MW) 8": "", - "Production cost curve (MW) 9": "", - "Production cost curve (MW) 10": "", - "Startup costs ($) 1": 300, - "Startup costs ($) 2": 400, - "Startup costs ($) 3": "", - "Startup costs ($) 4": "", - "Startup costs ($) 5": "", - "Startup delays (h) 1": 1, - "Startup delays (h) 2": 4, - "Startup delays (h) 3": "", - "Startup delays (h) 4": "", - "Startup delays (h) 5": "", - "Must run?": false, - }); -}); - -test("generate CSV", () => { - const [data, columns] = generateBusesData(TEST_DATA_1); - const actualCsv = generateCsv(data, columns); - const expectedCsv = - "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + - "b1,35.79534,34.38835,33.45083,32.89729,33.25044\n" + - "b2,14.03739,13.48563,13.11797,12.9009,13.03939\n" + - "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; - assert.strictEqual(actualCsv, expectedCsv); -}); - -test("parse CSV (Buses)", () => { - const csvContents = - "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + - "b1,0,1,2,3,4\n" + - "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; - const [newBuses, err] = parseCsv(csvContents, BusesColumnSpec, TEST_DATA_1); - assert(err === null); - assert.deepEqual(newBuses, { - b1: { - "Load (MW)": [0, 1, 2, 3, 4], - }, - b3: { - "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268], - }, - }); -}); - -test("parse CSV with duplicated names", () => { - const csvContents = - "Name,Load (MW) 00:00,Load (MW) 01:00,Load (MW) 02:00,Load (MW) 03:00,Load (MW) 04:00\n" + - "b1,0,0,0,0,0\n" + - "b1,0,0,0,0,0"; - const [, err] = parseCsv(csvContents, BusesColumnSpec, TEST_DATA_1); - assert(err !== null); - assert.equal(err.message, `Name "b1" is duplicated (row 2)`); -}); diff --git a/web/src/components/Common/Forms/DataTable.tsx b/web/src/components/Common/Forms/DataTable.tsx index 9c816c0..a2e6a54 100644 --- a/web/src/components/Common/Forms/DataTable.tsx +++ b/web/src/components/Common/Forms/DataTable.tsx @@ -13,7 +13,7 @@ import { import { ValidationError } from "../../../core/Validation/validate"; import { UnitCommitmentScenario } from "../../../core/fixtures"; import Papa from "papaparse"; -import { parseNumber } from "../../../core/Operations/commonOps"; +import { parseBool, parseNumber } from "../../../core/Operations/commonOps"; export interface ColumnSpec { title: string; @@ -228,11 +228,12 @@ export const parseCsv = ( case "string": data[name][spec.title] = row[spec.title]; break; - case "number": + case "number": { const [val, err] = parseNumber(row[spec.title]); if (err) return [null, { message: err.message + rowRef }]; data[name][spec.title] = val; break; + } case "busRef": const busName = row[spec.title]; if (!(busName in scenario.Buses)) { @@ -243,15 +244,36 @@ export const parseCsv = ( } data[name][spec.title] = row[spec.title]; break; - case "number[T]": + case "number[T]": { data[name][spec.title] = Array(timeslots.length); - for (let i = 0; i < timeslots.length; i++) { - data[name][spec.title][i] = parseFloat( - row[`${spec.title} ${timeslots[i]}`], - ); + const [vf, err] = parseNumber(row[`${spec.title} ${timeslots[i]}`]); + if (err) return [data, { message: err.message + rowRef }]; + data[name][spec.title][i] = vf; + } + break; + } + case "number[N]": { + data[name][spec.title] = Array(spec.length).fill(0); + for (let i = 0; i < spec.length!; i++) { + let v = row[`${spec.title} ${i + 1}`]; + if (v.trim() === "") { + data[name][spec.title].splice(i, spec.length! - i); + break; + } else { + const [vf, err] = parseNumber(row[`${spec.title} ${i + 1}`]); + if (err) return [data, { message: err.message + rowRef }]; + data[name][spec.title][i] = vf; + } } break; + } + case "boolean": { + const [val, err] = parseBool(row[spec.title]); + if (err) return [data, { message: err.message + rowRef }]; + data[name][spec.title] = val; + break; + } default: throw Error(`Unknown type: ${spec.type}`); } diff --git a/web/src/core/Operations/commonOps.test.ts b/web/src/core/Operations/commonOps.test.ts new file mode 100644 index 0000000..706d16c --- /dev/null +++ b/web/src/core/Operations/commonOps.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { parseBool } from "./commonOps"; +import assert from "node:assert"; + +test("parseBool", () => { + // True values + for (const str of ["true", "TRUE", "1"]) { + let [v, err] = parseBool(str); + assert(!err); + assert.equal(v, true); + } + + // False values + for (const str of ["false", "FALSE", "0"]) { + let [v, err] = parseBool(str); + assert(!err); + assert.equal(v, false); + } + + // Invalid values + for (const str of ["qwe", ""]) { + let [, err] = parseBool(str); + assert(err); + } +}); diff --git a/web/src/core/Operations/commonOps.ts b/web/src/core/Operations/commonOps.ts index fc40bdd..e8854ac 100644 --- a/web/src/core/Operations/commonOps.ts +++ b/web/src/core/Operations/commonOps.ts @@ -51,6 +51,18 @@ export const parseNumber = ( } }; +export const parseBool = ( + valueStr: string, +): [boolean, ValidationError | null] => { + if (["true", "1"].includes(valueStr.toLowerCase())) { + return [true, null]; + } + if (["false", "0"].includes(valueStr.toLowerCase())) { + return [false, null]; + } + return [true, { message: `"${valueStr}" is not a valid boolean value` }]; +}; + export const changeStringData = ( field: string, newValue: string, diff --git a/web/src/core/Operations/generatorOps.ts b/web/src/core/Operations/generatorOps.ts index 8968aa5..d8ceda0 100644 --- a/web/src/core/Operations/generatorOps.ts +++ b/web/src/core/Operations/generatorOps.ts @@ -63,8 +63,8 @@ export const createThermalUnit = ( [name]: { Bus: busName, Type: "Thermal", - "Production cost curve (MW)": [0], - "Production cost curve ($)": [0], + "Production cost curve (MW)": [0, 100], + "Production cost curve ($)": [0, 10], "Startup costs ($)": [0], "Startup delays (h)": [1], "Ramp up limit (MW)": "",