From 8827f9e6c8383ff65e98f7841d82fba1509d6e61 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Mon, 23 Jun 2025 16:06:23 -0500 Subject: [PATCH] web: profiled units: Allow CSV upload --- .../components/CaseBuilder/Buses/Buses.tsx | 127 ++++++++++++------ .../components/CaseBuilder/Buses/BusesCsv.ts | 64 --------- .../components/CaseBuilder/CaseBuilder.tsx | 81 +---------- .../CaseBuilder/Parameters/Parameters.tsx | 34 ++++- .../ProfiledUnits/ProfiledUnits.tsx | 101 +++++++++----- .../components/Common/Forms/DataTable.test.ts | 51 +++---- web/src/components/Common/Forms/DataTable.tsx | 110 ++++++++++----- 7 files changed, 276 insertions(+), 292 deletions(-) delete mode 100644 web/src/components/CaseBuilder/Buses/BusesCsv.ts diff --git a/web/src/components/CaseBuilder/Buses/Buses.tsx b/web/src/components/CaseBuilder/Buses/Buses.tsx index 09f5d30..fb81a0a 100644 --- a/web/src/components/CaseBuilder/Buses/Buses.tsx +++ b/web/src/components/CaseBuilder/Buses/Buses.tsx @@ -16,58 +16,46 @@ import FileUploadElement from "../../Common/Buttons/FileUploadElement"; import { useRef } from "react"; import { ValidationError } from "../../../core/Validation/validate"; import DataTable, { - addNameColumn, - addTimeseriesColumn, ColumnSpec, generateCsv, generateTableColumns, generateTableData, + parseCsv, } from "../../Common/Forms/DataTable"; import { UnitCommitmentScenario } from "../../../core/fixtures"; import { ColumnDefinition } from "tabulator-tables"; -import { parseBusesCsv } from "./BusesCsv"; +import { + changeBusData, + createBus, + deleteBus, + renameBus, +} from "../../../core/Operations/busOperations"; + +export const BusesColumnSpec: ColumnSpec[] = [ + { + title: "Name", + type: "string", + width: 150, + }, + { + title: "Load (MW)", + type: "number[]", + width: 60, + }, +]; export const generateBusesData = ( scenario: UnitCommitmentScenario, ): [any[], ColumnDefinition[]] => { - const colSpecs: ColumnSpec[] = [ - { - title: "Name", - type: "string", - width: 150, - }, - { - title: "Load (MW)", - type: "number[]", - width: 60, - }, - ]; - const columns = generateTableColumns(scenario, colSpecs); - const data = generateTableData(scenario.Buses, colSpecs, scenario); + const columns = generateTableColumns(scenario, BusesColumnSpec); + const data = generateTableData(scenario.Buses, BusesColumnSpec, scenario); return [data, columns]; }; - -export const generateBusesColumns = ( - scenario: UnitCommitmentScenario, -): ColumnDefinition[] => { - const columns: ColumnDefinition[] = []; - addNameColumn(columns); - addTimeseriesColumn(scenario, "Load (MW)", columns); - return columns; -}; - interface BusesProps { scenario: UnitCommitmentScenario; - onBusCreated: () => void; - onBusDataChanged: ( - bus: string, - field: string, - newValue: string, - ) => ValidationError | null; - onBusDeleted: (bus: string) => ValidationError | null; - onBusRenamed: (oldName: string, newName: string) => ValidationError | null; onDataChanged: (scenario: UnitCommitmentScenario) => void; + onError: (msg: string) => void; } function BusesComponent(props: BusesProps) { @@ -81,19 +69,70 @@ function BusesComponent(props: BusesProps) { const onUpload = () => { fileUploadElem.current!.showFilePicker((csvContents: any) => { - const newScenario = parseBusesCsv(props.scenario, csvContents); + const [newBuses, err] = parseCsv( + csvContents, + BusesColumnSpec, + props.scenario, + ); + if (err) { + props.onError(err.message); + return; + } + const newScenario = { + ...props.scenario, + Buses: newBuses, + }; props.onDataChanged(newScenario); }); }; + const onAdd = () => { + const newScenario = createBus(props.scenario); + props.onDataChanged(newScenario); + }; + + const onDataChanged = ( + bus: string, + field: string, + newValue: string, + ): ValidationError | null => { + const [newScenario, err] = changeBusData( + bus, + field, + newValue, + props.scenario, + ); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + + const onDelete = (bus: string): ValidationError | null => { + const newScenario = deleteBus(bus, props.scenario); + props.onDataChanged(newScenario); + return null; + }; + + const onRename = ( + oldName: string, + newName: string, + ): ValidationError | null => { + const [newScenario, err] = renameBus(oldName, newName, props.scenario); + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + return (
- + generateBusesData(props.scenario)} /> diff --git a/web/src/components/CaseBuilder/Buses/BusesCsv.ts b/web/src/components/CaseBuilder/Buses/BusesCsv.ts deleted file mode 100644 index a44b7dc..0000000 --- a/web/src/components/CaseBuilder/Buses/BusesCsv.ts +++ /dev/null @@ -1,64 +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 { Buses, UnitCommitmentScenario } from "../../../core/fixtures"; -import Papa from "papaparse"; -import { generateBusesColumns } from "./Buses"; - -export const parseBusesCsv = ( - scenario: UnitCommitmentScenario, - csvData: string, -): UnitCommitmentScenario => { - const results = Papa.parse(csvData, { - header: true, - skipEmptyLines: true, - transformHeader: (header) => header.trim(), - transform: (value) => value.trim(), - }); - - // Check for parsing errors - if (results.errors.length > 0) { - throw Error(`Invalid CSV: Parsing error: ${results.errors}`); - } - - // Check CSV headers - const expectedFields = generateBusesColumns(scenario).map( - (col) => col.field, - )!; - const actualFields = results.meta.fields!; - for (let i = 0; i < expectedFields.length; i++) { - if (expectedFields[i] !== actualFields[i]) { - throw Error(`Invalid CSV: Header mismatch at column ${i + 1}"`); - } - } - - // Parse each row - const T = getNumTimesteps(scenario); - const buses: Buses = {}; - for (let i = 0; i < results.data.length; i++) { - const row = results.data[i] as { [key: string]: any }; - const busName = row["Name"] as string; - const busLoad: number[] = Array(T); - for (let j = 0; j < T; j++) { - busLoad[j] = parseFloat(row[`Load ${j}`]); - } - buses[busName] = { - "Load (MW)": busLoad, - }; - } - return { - ...scenario, - Buses: buses, - }; -}; - -function getNumTimesteps(scenario: UnitCommitmentScenario) { - return ( - (scenario.Parameters["Time horizon (h)"] * - scenario.Parameters["Time step (min)"]) / - 60 - ); -} diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index cfa7c3c..96db751 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -17,19 +17,8 @@ import "tabulator-tables/dist/css/tabulator.min.css"; import "../Common/Forms/Tables.css"; import { useState } from "react"; import Footer from "./Footer"; -import { validate, ValidationError } from "../../core/Validation/validate"; +import { validate } from "../../core/Validation/validate"; import { offerDownload } from "../Common/io"; -import { - changeBusData, - createBus, - deleteBus, - renameBus, -} from "../../core/Operations/busOperations"; -import { - changeParameter, - changeTimeHorizon, - changeTimeStep, -} from "../../core/Operations/parameterOperations"; import { preprocess } from "../../core/Operations/preprocessing"; import Toast from "../Common/Forms/Toast"; import ProfiledUnitsComponent from "./ProfiledUnits/ProfiledUnits"; @@ -59,50 +48,10 @@ const CaseBuilder = () => { ); }; - const onBusCreated = () => { - const newScenario = createBus(scenario); - setAndSaveScenario(newScenario); - }; - - const onBusDataChanged = ( - bus: string, - field: string, - newValue: string, - ): ValidationError | null => { - const [newScenario, err] = changeBusData(bus, field, newValue, scenario); - if (err) { - setToastMessage(err.message); - return err; - } - setAndSaveScenario(newScenario); - return null; - }; - - const onBusDeleted = (bus: string): ValidationError | null => { - const newScenario = deleteBus(bus, scenario); - setAndSaveScenario(newScenario); - return null; - }; - - const onBusRenamed = ( - oldName: string, - newName: string, - ): ValidationError | null => { - const [newScenario, err] = renameBus(oldName, newName, scenario); - if (err) { - setToastMessage(err.message); - return err; - } - setAndSaveScenario(newScenario); - return null; - }; - const onDataChanged = (newScenario: UnitCommitmentScenario) => { setAndSaveScenario(newScenario); }; - const onProfiledUnitCreated = () => {}; - const onLoad = (scenario: UnitCommitmentScenario) => { const preprocessed = preprocess( scenario, @@ -119,42 +68,24 @@ const CaseBuilder = () => { setToastMessage("Data loaded successfully"); }; - const onParameterChanged = (key: string, value: string) => { - let newScenario, err; - if (key === "Time horizon (h)") { - [newScenario, err] = changeTimeHorizon(scenario, value); - } else if (key === "Time step (min)") { - [newScenario, err] = changeTimeStep(scenario, value); - } else { - [newScenario, err] = changeParameter(scenario, key, value); - } - if (err) { - setToastMessage(err.message); - return err; - } - setAndSaveScenario(newScenario); - return null; - }; - return (
diff --git a/web/src/components/CaseBuilder/Parameters/Parameters.tsx b/web/src/components/CaseBuilder/Parameters/Parameters.tsx index 16b19ee..d4331cc 100644 --- a/web/src/components/CaseBuilder/Parameters/Parameters.tsx +++ b/web/src/components/CaseBuilder/Parameters/Parameters.tsx @@ -8,14 +8,36 @@ import SectionHeader from "../../Common/SectionHeader/SectionHeader"; import Form from "../../Common/Forms/Form"; import TextInputRow from "../../Common/Forms/TextInputRow"; import { UnitCommitmentScenario } from "../../../core/fixtures"; -import { ValidationError } from "../../../core/Validation/validate"; +import { + changeParameter, + changeTimeHorizon, + changeTimeStep, +} from "../../../core/Operations/parameterOperations"; interface ParametersProps { scenario: UnitCommitmentScenario; - onParameterChanged: (key: string, value: string) => ValidationError | null; + onError: (msg: string) => void; + onDataChanged: (scenario: UnitCommitmentScenario) => void; } function Parameters(props: ParametersProps) { + const onDataChanged = (key: string, value: string) => { + let newScenario, err; + if (key === "Time horizon (h)") { + [newScenario, err] = changeTimeHorizon(props.scenario, value); + } else if (key === "Time step (min)") { + [newScenario, err] = changeTimeStep(props.scenario, value); + } else { + [newScenario, err] = changeParameter(props.scenario, key, value); + } + if (err) { + props.onError(err.message); + return err; + } + props.onDataChanged(newScenario); + return null; + }; + return (
@@ -25,23 +47,21 @@ function Parameters(props: ParametersProps) { unit="h" tooltip="Length of the planning horizon (in hours)." initialValue={`${props.scenario.Parameters["Time horizon (h)"]}`} - onChange={(v) => props.onParameterChanged("Time horizon (h)", v)} + onChange={(v) => onDataChanged("Time horizon (h)", v)} /> props.onParameterChanged("Time step (min)", v)} + onChange={(v) => onDataChanged("Time step (min)", v)} /> - props.onParameterChanged("Power balance penalty ($/MW)", v) - } + onChange={(v) => onDataChanged("Power balance penalty ($/MW)", v)} />
diff --git a/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx b/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx index 3dd3df3..a016f85 100644 --- a/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx +++ b/web/src/components/CaseBuilder/ProfiledUnits/ProfiledUnits.tsx @@ -16,66 +16,94 @@ import DataTable, { generateCsv, generateTableColumns, generateTableData, + parseCsv, } from "../../Common/Forms/DataTable"; import { UnitCommitmentScenario } from "../../../core/fixtures"; import { ColumnDefinition } from "tabulator-tables"; import { offerDownload } from "../../Common/io"; +import FileUploadElement from "../../Common/Buttons/FileUploadElement"; +import { useRef } from "react"; interface ProfiledUnitsProps { scenario: UnitCommitmentScenario; - onProfiledUnitCreated: () => void; + onDataChanged: (scenario: UnitCommitmentScenario) => void; + onError: (msg: string) => void; } +const ProfiledUnitsColumnSpec: ColumnSpec[] = [ + { + title: "Name", + type: "string", + width: 150, + }, + { + title: "Bus", + type: "string", + width: 150, + }, + { + title: "Cost ($/MW)", + type: "number", + width: 100, + }, + { + title: "Maximum power (MW)", + type: "number[]", + width: 60, + }, + { + title: "Minimum power (MW)", + type: "number[]", + width: 60, + }, +]; + const generateProfiledUnitsData = ( scenario: UnitCommitmentScenario, ): [any[], ColumnDefinition[]] => { - const colSpecs: ColumnSpec[] = [ - { - title: "Name", - type: "string", - width: 150, - }, - { - title: "Bus", - type: "string", - width: 150, - }, - { - title: "Cost ($/MW)", - type: "number", - width: 100, - }, - { - title: "Maximum power (MW)", - type: "number[]", - width: 60, - }, - { - title: "Minimum power (MW)", - type: "number[]", - width: 60, - }, - ]; - const columns = generateTableColumns(scenario, colSpecs); - const data = generateTableData(scenario.Generators, colSpecs, scenario); + const columns = generateTableColumns(scenario, ProfiledUnitsColumnSpec); + const data = generateTableData( + scenario.Generators, + ProfiledUnitsColumnSpec, + scenario, + ); return [data, columns]; }; const ProfiledUnitsComponent = (props: ProfiledUnitsProps) => { + const fileUploadElem = useRef(null); + const onDownload = () => { const [data, columns] = generateProfiledUnitsData(props.scenario); const csvContents = generateCsv(data, columns); offerDownload(csvContents, "text/csv", "profiled_units.csv"); }; - const onUpload = () => {}; + + const onUpload = () => { + fileUploadElem.current!.showFilePicker((csvContents: any) => { + const [newGenerators, err] = parseCsv( + csvContents, + ProfiledUnitsColumnSpec, + props.scenario, + ); + if (err) { + props.onError(err.message); + return; + } + const newScenario = { + ...props.scenario, + Generators: newGenerators, + }; + props.onDataChanged(newScenario); + }); + }; + + const onAdd = () => {}; + return (
- + { }} generateData={() => generateProfiledUnitsData(props.scenario)} /> +
); }; diff --git a/web/src/components/Common/Forms/DataTable.test.ts b/web/src/components/Common/Forms/DataTable.test.ts index e4c2e1d..08f1618 100644 --- a/web/src/components/Common/Forms/DataTable.test.ts +++ b/web/src/components/Common/Forms/DataTable.test.ts @@ -5,9 +5,11 @@ */ import assert from "node:assert"; -import { parseBusesCsv } from "../../CaseBuilder/Buses/BusesCsv"; -import { generateBusesData } from "../../CaseBuilder/Buses/Buses"; -import { generateCsv } from "./DataTable"; +import { + BusesColumnSpec, + generateBusesData, +} from "../../CaseBuilder/Buses/Buses"; +import { generateCsv, parseCsv } from "./DataTable"; import { TEST_DATA_1 } from "../../../core/fixtures.test"; test("generate CSV", () => { @@ -21,38 +23,19 @@ test("generate CSV", () => { assert.strictEqual(actualCsv, expectedCsv); }); -test("parse valid CSV", () => { - // const csvContents = - // "Name,Load 0,Load 1,Load 2,Load 3,Load 4\n" + - // "b1,0,1,2,3,4\n" + - // "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; - // const newScenario = parseBusesCsv(FixturesTest, csvContents); - // assert.deepEqual(newScenario.Buses, { - // b1: { - // "Load (MW)": [0, 1, 2, 3, 4], - // }, - // b3: { - // "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268], - // }, - // }); -}); - -test("parse invalid CSV (wrong headers)", () => { +test("parse CSV", () => { const csvContents = - "Name,Load 5,Load 7,Load 23,Load 3,Load 4\n" + + "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"; - expect(() => { - parseBusesCsv(TEST_DATA_1, csvContents); - }).toThrow(Error); -}); - -test("parse invalid CSV (wrong data length)", () => { - const csvContents = - "Name,Load 0,Load 1,Load 2,Load 3,Load 4\n" + - "b1,0,1,2,3\n" + - "b3,27.3729,26.29698,25.58005,25.15675,25.4268"; - expect(() => { - parseBusesCsv(TEST_DATA_1, csvContents); - }).toThrow(Error); + 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], + }, + }); }); diff --git a/web/src/components/Common/Forms/DataTable.tsx b/web/src/components/Common/Forms/DataTable.tsx index 56eb92f..5134aaf 100644 --- a/web/src/components/Common/Forms/DataTable.tsx +++ b/web/src/components/Common/Forms/DataTable.tsx @@ -12,6 +12,7 @@ import { } from "tabulator-tables"; import { ValidationError } from "../../../core/Validation/validate"; import { UnitCommitmentScenario } from "../../../core/fixtures"; +import Papa from "papaparse"; export interface ColumnSpec { title: string; @@ -126,17 +127,85 @@ export const generateCsv = (data: any[], columns: ColumnDefinition[]) => { return `${csvHeader}\n${csvBody}`; }; -export const floatFormatter = (cell: CellComponent) => { - return parseFloat(cell.getValue()).toFixed(1); -}; +export const parseCsv = ( + csvContents: string, + colSpecs: ColumnSpec[], + scenario: UnitCommitmentScenario, +): [any, ValidationError | null] => { + // Parse contents + const csv = Papa.parse(csvContents, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim(), + transform: (value) => value.trim(), + }); -export const addNameColumn = (columns: ColumnDefinition[]) => { - columns.push({ - ...columnsCommonAttrs, - title: "Name", - field: "Name", - minWidth: 150, + // Check for parsing errors + if (csv.errors.length > 0) { + console.error(csv.errors); + return [null, { message: "Could not parse CSV file" }]; + } + + // Check CSV headers + const columns = generateTableColumns(scenario, colSpecs); + const expectedHeader: string[] = []; + columns.forEach((column) => { + if (column.columns) { + column.columns.forEach((subcolumn) => { + expectedHeader.push(subcolumn.field!); + }); + } else { + expectedHeader.push(column.field!); + } }); + const actualHeader = csv.meta.fields!; + for (let i = 0; i < expectedHeader.length; i++) { + if (expectedHeader[i] !== actualHeader[i]) { + return [ + null, + { + message: `Invalid CSV: Header mismatch at column ${i + 1}. + Expected "${expectedHeader[i]}", found "${actualHeader[i]}"`, + }, + ]; + } + } + + // Parse each row + const timeslots = generateTimeslots(scenario); + const data: { [key: string]: any } = {}; + for (let i = 0; i < csv.data.length; i++) { + const row = csv.data[i] as { [key: string]: any }; + const name = row["Name"] as string; + data[name] = {}; + + for (const spec of colSpecs) { + if (spec.title === "Name") continue; + switch (spec.type) { + case "string": + case "number": + data[name][spec.title] = row[spec.title]; + break; + case "number[]": + 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]}`], + ); + } + break; + default: + console.error(`Unknown type: ${spec.type}`); + } + } + } + + return [data, null]; +}; + +export const floatFormatter = (cell: CellComponent) => { + return parseFloat(cell.getValue()).toFixed(1); }; export const generateTimeslots = (scenario: UnitCommitmentScenario) => { @@ -156,29 +225,6 @@ export const generateTimeslots = (scenario: UnitCommitmentScenario) => { return timeslots; }; -export const addTimeseriesColumn = ( - scenario: UnitCommitmentScenario, - title: string, - columns: ColumnDefinition[], - minWidth: number = 65, -) => { - const timeSlots = generateTimeslots(scenario); - const subColumns: ColumnDefinition[] = []; - timeSlots.forEach((t) => { - subColumns.push({ - ...columnsCommonAttrs, - title: `${t}`, - field: `${title} ${t}`, - minWidth: minWidth, - formatter: floatFormatter, - }); - }); - columns.push({ - title: title, - columns: subColumns, - }); -}; - export const columnsCommonAttrs: ColumnDefinition = { headerHozAlign: "left", hozAlign: "left",