diff --git a/web/src/components/CaseBuilder/Buses.tsx b/web/src/components/CaseBuilder/Buses.tsx index 5f8a585..f46c4ae 100644 --- a/web/src/components/CaseBuilder/Buses.tsx +++ b/web/src/components/CaseBuilder/Buses.tsx @@ -31,16 +31,17 @@ import { deleteBus, renameBus, } from "../../core/Operations/busOps"; +import { CaseBuilderSectionProps } from "./CaseBuilder"; export const BusesColumnSpec: ColumnSpec[] = [ { title: "Name", type: "string", - width: 150, + width: 100, }, { title: "Load (MW)", - type: "number[]", + type: "number[T]", width: 60, }, ]; @@ -52,13 +53,8 @@ export const generateBusesData = ( const data = generateTableData(scenario.Buses, BusesColumnSpec, scenario); return [data, columns]; }; -interface BusesProps { - scenario: UnitCommitmentScenario; - onDataChanged: (scenario: UnitCommitmentScenario) => void; - onError: (msg: string) => void; -} -function BusesComponent(props: BusesProps) { +function BusesComponent(props: CaseBuilderSectionProps) { const fileUploadElem = useRef(null); const onDownload = () => { diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index 66be4de..16aed1b 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -6,7 +6,7 @@ import Header from "./Header"; import Parameters from "./Parameters"; -import Buses from "./Buses"; +import BusesComponent from "./Buses"; import { BLANK_SCENARIO, TEST_SCENARIO, @@ -22,6 +22,13 @@ import { offerDownload } from "../Common/io"; import { preprocess } from "../../core/Operations/preprocessing"; import Toast from "../Common/Forms/Toast"; import ProfiledUnitsComponent from "./ProfiledUnits"; +import ThermalUnitsComponent from "./ThermalUnits"; + +export interface CaseBuilderSectionProps { + scenario: UnitCommitmentScenario; + onDataChanged: (scenario: UnitCommitmentScenario) => void; + onError: (msg: string) => void; +} const CaseBuilder = () => { const [scenario, setScenario] = useState(() => { @@ -99,7 +106,12 @@ const CaseBuilder = () => { onDataChanged={onDataChanged} onError={setToastMessage} /> - + void; - onError: (msg: string) => void; -} +import { CaseBuilderSectionProps } from "./CaseBuilder"; export const ProfiledUnitsColumnSpec: ColumnSpec[] = [ { title: "Name", type: "string", - width: 150, + width: 100, }, { title: "Bus", type: "busRef", - width: 150, + width: 100, }, { title: "Cost ($/MW)", @@ -55,12 +53,12 @@ export const ProfiledUnitsColumnSpec: ColumnSpec[] = [ }, { title: "Maximum power (MW)", - type: "number[]", + type: "number[T]", width: 60, }, { title: "Minimum power (MW)", - type: "number[]", + type: "number[T]", width: 60, }, ]; @@ -70,14 +68,14 @@ const generateProfiledUnitsData = ( ): [any[], ColumnDefinition[]] => { const columns = generateTableColumns(scenario, ProfiledUnitsColumnSpec); const data = generateTableData( - scenario.Generators, + getProfiledGenerators(scenario), ProfiledUnitsColumnSpec, scenario, ); return [data, columns]; }; -const ProfiledUnitsComponent = (props: ProfiledUnitsProps) => { +const ProfiledUnitsComponent = (props: CaseBuilderSectionProps) => { const fileUploadElem = useRef(null); const onDownload = () => { diff --git a/web/src/components/CaseBuilder/ThermalUnits.tsx b/web/src/components/CaseBuilder/ThermalUnits.tsx new file mode 100644 index 0000000..90f5b76 --- /dev/null +++ b/web/src/components/CaseBuilder/ThermalUnits.tsx @@ -0,0 +1,228 @@ +/* + * 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 DataTable, { + ColumnSpec, + generateTableColumns, + generateTableData, +} from "../Common/Forms/DataTable"; +import { CaseBuilderSectionProps } from "./CaseBuilder"; +import { useRef } from "react"; +import FileUploadElement from "../Common/Buttons/FileUploadElement"; +import { ValidationError } from "../../core/Validation/validate"; +import SectionHeader from "../Common/SectionHeader/SectionHeader"; +import SectionButton from "../Common/Buttons/SectionButton"; +import { + faDownload, + faPlus, + faUpload, +} from "@fortawesome/free-solid-svg-icons"; +import { + getThermalGenerators, + UnitCommitmentScenario, +} from "../../core/fixtures"; +import { ColumnDefinition } from "tabulator-tables"; + +export const ThermalUnitsColumnSpec: ColumnSpec[] = [ + { + title: "Name", + type: "string", + width: 100, + }, + { + title: "Bus", + type: "busRef", + width: 100, + }, + { + title: "Production cost curve (MW)", + type: "number[N]", + length: 10, + width: 60, + }, + { + title: "Production cost curve ($)", + type: "number[N]", + length: 10, + width: 60, + }, + { + title: "Startup costs ($)", + type: "number[N]", + length: 5, + width: 60, + }, + { + title: "Startup delays (h)", + type: "number[N]", + length: 5, + width: 60, + }, + { + title: "Min uptime (h)", + type: "number", + width: 80, + }, + { + title: "Min downtime (h)", + type: "number", + width: 100, + }, + { + title: "Ramp up limit (MW)", + type: "number", + width: 100, + }, + { + title: "Ramp down limit (MW)", + type: "number", + width: 100, + }, + { + title: "Startup limit (MW)", + type: "number", + width: 80, + }, + { + title: "Shutdown limit (MW)", + type: "number", + width: 100, + }, + { + title: "Initial status (h)", + type: "number", + width: 80, + }, + { + title: "Initial power (MW)", + type: "number", + width: 100, + }, + { + title: "Must run?", + type: "boolean", + width: 80, + }, +]; + +const generateThermalUnitsData = ( + scenario: UnitCommitmentScenario, +): [any[], ColumnDefinition[]] => { + const columns = generateTableColumns(scenario, ThermalUnitsColumnSpec); + const data = generateTableData( + getThermalGenerators(scenario), + ThermalUnitsColumnSpec, + scenario, + ); + return [data, columns]; +}; + +const ThermalUnitsComponent = (props: CaseBuilderSectionProps) => { + const fileUploadElem = useRef(null); + + const onDownload = () => { + // const [data, columns] = generateThermalUnitsData(props.scenario); + // const csvContents = generateCsv(data, columns); + // offerDownload(csvContents, "text/csv", "profiled_units.csv"); + }; + + 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); + // }); + }; + + const onAdd = () => { + // const [newScenario, err] = createThermalUnit(props.scenario); + // if (err) { + // props.onError(err.message); + // return; + // } + // props.onDataChanged(newScenario); + }; + + const onDelete = (name: string): ValidationError | null => { + // const newScenario = deleteGenerator(name, props.scenario); + // props.onDataChanged(newScenario); + return null; + }; + + const onDataChanged = ( + name: string, + field: string, + newValue: string, + ): ValidationError | null => { + // const [newScenario, err] = changeThermalUnitData( + // 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 ( +
+ + + + + + generateThermalUnitsData(props.scenario)} + /> + +
+ ); +}; + +export default ThermalUnitsComponent; diff --git a/web/src/components/Common/Forms/DataTable.test.ts b/web/src/components/Common/Forms/DataTable.test.ts index 05a0793..404e533 100644 --- a/web/src/components/Common/Forms/DataTable.test.ts +++ b/web/src/components/Common/Forms/DataTable.test.ts @@ -6,9 +6,71 @@ import assert from "node:assert"; import { BusesColumnSpec, generateBusesData } from "../../CaseBuilder/Buses"; -import { generateCsv, parseCsv } from "./DataTable"; +import { + floatFormatter, + generateCsv, + generateTableColumns, + parseCsv, +} from "./DataTable"; import { TEST_DATA_1 } from "../../../core/fixtures.test"; import { ProfiledUnitsColumnSpec } from "../../CaseBuilder/ProfiledUnits"; +import { ThermalUnitsColumnSpec } from "../../CaseBuilder/ThermalUnits"; + +test("generateTableColumns (ProfiledUnits)", () => { + const columns = generateTableColumns(TEST_DATA_1, ProfiledUnitsColumnSpec); + assert.equal(columns.length, 5); + assert.deepEqual(columns[0], { + editor: "input", + editorParams: { + selectContents: true, + }, + field: "Name", + formatter: "plaintext", + headerHozAlign: "left", + headerSort: false, + headerWordWrap: true, + hozAlign: "left", + minWidth: 100, + resizable: false, + title: "Name", + }); + assert.equal(columns[3]!["columns"]!.length, 5); + assert.deepEqual(columns[3]!["columns"]![0], { + editor: "input", + editorParams: { + selectContents: true, + }, + field: "Maximum power (MW) 00:00", + formatter: floatFormatter, + headerHozAlign: "left", + headerSort: false, + headerWordWrap: true, + hozAlign: "left", + minWidth: 60, + resizable: false, + title: "00:00", + }); +}); + +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("generate CSV", () => { const [data, columns] = generateBusesData(TEST_DATA_1); diff --git a/web/src/components/Common/Forms/DataTable.tsx b/web/src/components/Common/Forms/DataTable.tsx index b38b5a3..25e392c 100644 --- a/web/src/components/Common/Forms/DataTable.tsx +++ b/web/src/components/Common/Forms/DataTable.tsx @@ -17,7 +17,8 @@ import { parseNumber } from "../../../core/Operations/commonOps"; export interface ColumnSpec { title: string; - type: "string" | "number" | "number[]" | "busRef"; + type: "string" | "number" | "number[N]" | "number[T]" | "busRef" | "boolean"; + length?: number; width: number; } @@ -28,8 +29,10 @@ export const generateTableColumns = ( const timeSlots = generateTimeslots(scenario); const columns: ColumnDefinition[] = []; colSpecs.forEach((spec) => { + const subColumns: ColumnDefinition[] = []; switch (spec.type) { case "string": + case "boolean": case "busRef": columns.push({ ...columnsCommonAttrs, @@ -47,8 +50,7 @@ export const generateTableColumns = ( formatter: floatFormatter, }); break; - case "number[]": - const subColumns: ColumnDefinition[] = []; + case "number[T]": timeSlots.forEach((t) => { subColumns.push({ ...columnsCommonAttrs, @@ -63,6 +65,21 @@ export const generateTableColumns = ( columns: subColumns, }); break; + case "number[N]": + for (let i = 1; i <= spec.length!; i++) { + subColumns.push({ + ...columnsCommonAttrs, + title: `${i}`, + field: `${spec.title} ${i}`, + minWidth: spec.width, + formatter: floatFormatter, + }); + } + columns.push({ + title: spec.title, + columns: subColumns, + }); + break; default: throw Error(`Unknown type: ${spec.type}`); } @@ -93,7 +110,7 @@ export const generateTableData = ( case "busRef": entry[spec.title] = entryData[spec.title]; break; - case "number[]": + case "number[T]": for (let i = 0; i < timeslots.length; i++) { entry[`${spec.title} ${timeslots[i]}`] = entryData[spec.title][i]; } @@ -207,7 +224,7 @@ export const parseCsv = ( } data[name][spec.title] = row[spec.title]; break; - case "number[]": + case "number[T]": data[name][spec.title] = Array(timeslots.length); for (let i = 0; i < timeslots.length; i++) { diff --git a/web/src/core/Operations/commonOps.ts b/web/src/core/Operations/commonOps.ts index da17787..fc40bdd 100644 --- a/web/src/core/Operations/commonOps.ts +++ b/web/src/core/Operations/commonOps.ts @@ -143,7 +143,7 @@ export const changeData = ( return changeBusRefData(fieldName, newValueStr, container, scenario); case "number": return changeNumberData(fieldName, newValueStr, container); - case "number[]": + case "number[T]": return changeNumberVecData( fieldName, fieldTime!, diff --git a/web/src/core/Operations/generatorOps.test.ts b/web/src/core/Operations/generatorOps.test.ts index 4302ce3..a2616ba 100644 --- a/web/src/core/Operations/generatorOps.test.ts +++ b/web/src/core/Operations/generatorOps.test.ts @@ -16,28 +16,13 @@ import { test("createProfiledUnit", () => { const [newScenario, err] = createProfiledUnit(TEST_DATA_1); assert(err === null); - assert.deepEqual(newScenario.Generators, { - pu1: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 12.5, - "Maximum power (MW)": [10, 12, 13, 15, 20], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, - pu2: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 120, - "Maximum power (MW)": [50, 50, 50, 50, 50], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, - pu3: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 0, - "Maximum power (MW)": [0, 0, 0, 0, 0], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, + assert.equal(Object.keys(newScenario.Generators).length, 4); + assert.deepEqual(newScenario.Generators["pu3"], { + Bus: "b1", + Type: "Profiled", + "Cost ($/MW)": 0, + "Maximum power (MW)": [0, 0, 0, 0, 0], + "Minimum power (MW)": [0, 0, 0, 0, 0], }); }); @@ -66,21 +51,12 @@ test("changeProfiledUnitData", () => { 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], - }, + assert.deepEqual(scenario.Generators["pu2"], { + Bus: "b3", + Type: "Profiled", + "Cost ($/MW)": 120, + "Maximum power (MW)": [50, 50, 50, 50, 50], + "Minimum power (MW)": [0, 0, 0, 0, 0], }); }); @@ -94,34 +70,26 @@ test("changeProfiledUnitData with invalid bus", () => { test("deleteGenerator", () => { const newScenario = deleteGenerator("pu1", TEST_DATA_1); - assert.deepEqual(newScenario.Generators, { - pu2: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 120, - "Maximum power (MW)": [50, 50, 50, 50, 50], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, - }); + assert.equal(Object.keys(newScenario.Generators).length, 2); + assert("gen1" in newScenario.Generators); + assert("pu2" in newScenario.Generators); }); test("renameGenerator", () => { const [newScenario, err] = renameGenerator("pu1", "pu5", TEST_DATA_1); assert(err === null); - assert.deepEqual(newScenario.Generators, { - pu5: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 12.5, - "Maximum power (MW)": [10, 12, 13, 15, 20], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, - pu2: { - Bus: "b1", - Type: "Profiled", - "Cost ($/MW)": 120, - "Maximum power (MW)": [50, 50, 50, 50, 50], - "Minimum power (MW)": [0, 0, 0, 0, 0], - }, + assert.deepEqual(newScenario.Generators["pu5"], { + Bus: "b1", + Type: "Profiled", + "Cost ($/MW)": 12.5, + "Maximum power (MW)": [10, 12, 13, 15, 20], + "Minimum power (MW)": [0, 0, 0, 0, 0], + }); + assert.deepEqual(newScenario.Generators["pu2"], { + Bus: "b1", + Type: "Profiled", + "Cost ($/MW)": 120, + "Maximum power (MW)": [50, 50, 50, 50, 50], + "Minimum power (MW)": [0, 0, 0, 0, 0], }); }); diff --git a/web/src/core/fixtures.test.ts b/web/src/core/fixtures.test.ts index f4714d6..cee79b1 100644 --- a/web/src/core/fixtures.test.ts +++ b/web/src/core/fixtures.test.ts @@ -13,6 +13,23 @@ export const TEST_DATA_1: UnitCommitmentScenario = { b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] }, }, Generators: { + gen1: { + 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, + }, pu1: { Bus: "b1", Type: "Profiled", diff --git a/web/src/core/fixtures.tsx b/web/src/core/fixtures.tsx index 038c91d..2061556 100644 --- a/web/src/core/fixtures.tsx +++ b/web/src/core/fixtures.tsx @@ -9,7 +9,7 @@ export interface Buses { } export interface Generators { - [name: string]: ProfiledUnit; + [name: string]: ProfiledUnit | ThermalUnit; } export interface ProfiledUnit { @@ -20,6 +20,24 @@ export interface ProfiledUnit { "Cost ($/MW)": number; } +export interface ThermalUnit { + Bus: string; + Type: "Thermal"; + "Production cost curve (MW)": number[]; + "Production cost curve ($)": number[]; + "Startup costs ($)": number[]; + "Startup delays (h)": number[]; + "Ramp up limit (MW)": number; + "Ramp down limit (MW)": number; + "Startup limit (MW)": number; + "Shutdown limit (MW)": number; + "Minimum downtime (h)": number; + "Minimum uptime (h)": number; + "Initial status (h)": number; + "Initial power (MW)": number; + "Must run?": boolean; +} + export interface UnitCommitmentScenario { Parameters: { Version: string; @@ -31,6 +49,29 @@ export interface UnitCommitmentScenario { Generators: Generators; } +const getTypedGenerators = ( + scenario: UnitCommitmentScenario, + type: string, +): { + [key: string]: T; +} => { + const selected: { [key: string]: T } = {}; + for (const [name, gen] of Object.entries(scenario.Generators)) { + if (gen["Type"] === type) selected[name] = gen as T; + } + return selected; +}; + +export const getProfiledGenerators = ( + scenario: UnitCommitmentScenario, +): { [key: string]: ProfiledUnit } => + getTypedGenerators(scenario, "Profiled"); + +export const getThermalGenerators = ( + scenario: UnitCommitmentScenario, +): { [key: string]: ThermalUnit } => + getTypedGenerators(scenario, "Thermal"); + export const BLANK_SCENARIO: UnitCommitmentScenario = { Parameters: { Version: "0.4", @@ -117,5 +158,22 @@ export const TEST_SCENARIO: UnitCommitmentScenario = { ], "Cost ($/MW)": 50.0, }, + gen1: { + 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, + }, }, };