mirror of
https://github.com/ANL-CEEESA/UnitCommitment.jl.git
synced 2025-12-06 00:08:52 -06:00
Compare commits
55 Commits
hotfix/0.4
...
35dd5ab1a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 35dd5ab1a9 | |||
| 5c7b8038a1 | |||
| c2d5e58c75 | |||
| 54b5b9dd7f | |||
| 395c041202 | |||
| 8e2769dc0e | |||
| e96557bed8 | |||
| 5b9727b0ba | |||
| 9f560df4f5 | |||
| 356046be7b | |||
| 201dd34b30 | |||
| fd95cefefc | |||
| 930c6a3277 | |||
| 3eb4cceb54 | |||
| 5fbf9af286 | |||
| 1c821dde14 | |||
| 055faefa28 | |||
| af7cb92282 | |||
| 872cb7a66e | |||
| 771eb5fa6d | |||
| 840eea9879 | |||
| 0dc0a5b460 | |||
| a09e25db0f | |||
| 53489c1638 | |||
| fff70cce67 | |||
| 869498fa97 | |||
| cac9d7e230 | |||
| eb3d39b1ab | |||
| 3bf028577e | |||
| 3f10ad23ca | |||
| 7c752e4c31 | |||
| dea5217916 | |||
| 012331c4bd | |||
| 1fea873ddf | |||
| d78700bdc6 | |||
| 02ddaf20dc | |||
| be500b920e | |||
| 9d48112bb9 | |||
| 5bfc3ffa55 | |||
| 1b37af82e3 | |||
| 86aababf33 | |||
| 8397571c11 | |||
| 8827f9e6c8 | |||
| eb862e5701 | |||
| 80d8bb838c | |||
| ee7a948a78 | |||
| 0cf93e7aa0 | |||
| 6d9bbaab4e | |||
| 957294f220 | |||
| d8feef5431 | |||
| 6469840f0a | |||
| 062b38514b | |||
| ea58cf1615 | |||
| facc9faabf | |||
|
|
d34378c660 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
*-off.md
|
||||||
*.bak
|
*.bak
|
||||||
*.gz
|
*.gz
|
||||||
*.ipynb
|
*.ipynb
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
.apdisk
|
.apdisk
|
||||||
.com.apple.timemachine.donotpresent
|
.com.apple.timemachine.donotpresent
|
||||||
.fseventsd
|
.fseventsd
|
||||||
|
.idea
|
||||||
.ipy*
|
.ipy*
|
||||||
.vscode
|
.vscode
|
||||||
Icon
|
Icon
|
||||||
@@ -32,12 +34,11 @@ benchmark/tables
|
|||||||
benchmark/tmp.json
|
benchmark/tmp.json
|
||||||
build
|
build
|
||||||
docs/_build
|
docs/_build
|
||||||
|
docs/src/tutorials/customizing.md
|
||||||
|
docs/src/tutorials/lmp.md
|
||||||
|
docs/src/tutorials/market.md
|
||||||
|
docs/src/tutorials/usage.md
|
||||||
instances/**/*.json
|
instances/**/*.json
|
||||||
instances/_source
|
instances/_source
|
||||||
local
|
local
|
||||||
notebooks
|
notebooks
|
||||||
docs/src/tutorials/usage.md
|
|
||||||
docs/src/tutorials/customizing.md
|
|
||||||
docs/src/tutorials/market.md
|
|
||||||
docs/src/tutorials/lmp.md
|
|
||||||
*-off.md
|
|
||||||
@@ -108,7 +108,7 @@ See official documentation at: https://anl-ceeesa.github.io/UnitCommitment.jl/
|
|||||||
|
|
||||||
If you use UnitCommitment.jl in your research (instances, models or algorithms), we kindly request that you cite the package as follows:
|
If you use UnitCommitment.jl in your research (instances, models or algorithms), we kindly request that you cite the package as follows:
|
||||||
|
|
||||||
* **Alinson S. Xavier, Aleksandr M. Kazachkov, Ogün Yurdakul, Feng Qiu**. "UnitCommitment.jl: A Julia/JuMP Optimization Package for Security-Constrained Unit Commitment (Version 0.4)". Zenodo (2024). [DOI: 10.5281/zenodo.4269874](https://doi.org/10.5281/zenodo.4269874).
|
* **Alinson S. Xavier, Aleksandr M. Kazachkov, Ogün Yurdakul, Jun He, Feng Qiu**. "UnitCommitment.jl: A Julia/JuMP Optimization Package for Security-Constrained Unit Commitment (Version 0.4)". Zenodo (2024). [DOI: 10.5281/zenodo.4269874](https://doi.org/10.5281/zenodo.4269874).
|
||||||
|
|
||||||
If you use the instances, we additionally request that you cite the original sources, as described in the documentation.
|
If you use the instances, we additionally request that you cite the original sources, as described in the documentation.
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Note that this curve also specifies the production limits. Specifically, the fir
|
|||||||
|
|
||||||
```@raw html
|
```@raw html
|
||||||
<center>
|
<center>
|
||||||
<img src="../assets/cost_curve.png" style="max-width: 500px"/>
|
<img src="../../assets/cost_curve.png" style="max-width: 500px"/>
|
||||||
<div><b>Figure 1.</b> Piecewise-linear production cost curve.</div>
|
<div><b>Figure 1.</b> Piecewise-linear production cost curve.</div>
|
||||||
<br/>
|
<br/>
|
||||||
</center>
|
</center>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Decomposition methods
|
# Decomposition methods
|
||||||
|
|
||||||
## 1. Time decomposition
|
## 1. Time decomposition for production cost modeling
|
||||||
|
|
||||||
Solving unit commitment instances that have long time horizons (for example, year-long 8760-hour instances) requires a substantial amount of computational power. To address this issue, UC.jl offers a time decomposition method, which breaks the instance down into multiple overlapping subproblems, solves them sequentially, then reassembles the solution.
|
Solving unit commitment instances that have long time horizons (for example, year-long 8760-hour instances in production cost modeling) requires a substantial amount of computational power. To address this issue, UC.jl offers a time decomposition method, which breaks the instance down into multiple overlapping subproblems, solves them sequentially, then reassembles the solution.
|
||||||
|
|
||||||
When solving a unit commitment instance with a dense time slot structure, computational complexity can become a significant challenge. For instance, if the instance contains hourly data for an entire year (8760 hours), solving such a model can require a substantial amount of computational power. To address this issue, UC.jl provides a time_decomposition method within the `optimize!` function. This method decomposes the problem into multiple sub-problems, solving them sequentially.
|
When solving a unit commitment instance with a dense time slot structure, computational complexity can become a significant challenge. For instance, if the instance contains hourly data for an entire year (8760 hours), solving such a model can require a substantial amount of computational power. To address this issue, UC.jl provides a time_decomposition method within the `optimize!` function. This method decomposes the problem into multiple sub-problems, solving them sequentially.
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ solution = UnitCommitment.optimize!(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Scenario decomposition with Progressive Hedging
|
## 2. Scenario decomposition with Progressive Hedging for stochstic UC
|
||||||
|
|
||||||
By default, UC.jl uses the Extensive Form (EF) when solving stochastic instances. This approach involves constructing a single JuMP model that contains data and decision variables for all scenarios. Although EF has optimality guarantees and performs well with small test cases, it can become computationally intractable for large instances or substantial number of scenarios.
|
By default, UC.jl uses the Extensive Form (EF) when solving stochastic instances. This approach involves constructing a single JuMP model that contains data and decision variables for all scenarios. Although EF has optimality guarantees and performs well with small test cases, it can become computationally intractable for large instances or substantial number of scenarios.
|
||||||
|
|
||||||
|
|||||||
BIN
test/fixtures/ucjl-0.2.json.gz
vendored
BIN
test/fixtures/ucjl-0.2.json.gz
vendored
Binary file not shown.
1
web/backend/.gitignore
vendored
Normal file
1
web/backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
jobs
|
||||||
17
web/backend/Project.toml
Normal file
17
web/backend/Project.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name = "Backend"
|
||||||
|
uuid = "948642ed-e3f9-4642-9296-0f1eaf40c938"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Alinson S. Xavier <git@axavier.org>"]
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193"
|
||||||
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
|
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
||||||
|
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
|
||||||
|
UnitCommitment = "64606440-39ea-11e9-0f29-3303a1d3d877"
|
||||||
|
|
||||||
|
[compat]
|
||||||
|
CodecZlib = "0.7.8"
|
||||||
|
HTTP = "1.10.19"
|
||||||
|
JSON = "0.21.4"
|
||||||
|
Random = "1.11.0"
|
||||||
12
web/backend/src/Backend.jl
Normal file
12
web/backend/src/Backend.jl
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||||
|
# Copyright (C) 2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
# Released under the modified BSD license. See COPYING.md for more details.
|
||||||
|
|
||||||
|
module Backend
|
||||||
|
|
||||||
|
basedir = joinpath(dirname(@__FILE__), "..")
|
||||||
|
|
||||||
|
include("jobs.jl")
|
||||||
|
include("server.jl")
|
||||||
|
|
||||||
|
end
|
||||||
99
web/backend/src/jobs.jl
Normal file
99
web/backend/src/jobs.jl
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||||
|
# Copyright (C) 2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
# Released under the modified BSD license. See COPYING.md for more details.
|
||||||
|
|
||||||
|
using UnitCommitment
|
||||||
|
|
||||||
|
import Base: put!
|
||||||
|
|
||||||
|
Base.@kwdef mutable struct JobProcessor
|
||||||
|
pending::Channel{String} = Channel{String}(Inf)
|
||||||
|
processing::Channel{String} = Channel{String}(Inf)
|
||||||
|
shutdown::Channel{Bool} = Channel{Bool}(1)
|
||||||
|
worker_task::Union{Task,Nothing} = nothing
|
||||||
|
optimizer = nothing
|
||||||
|
end
|
||||||
|
|
||||||
|
function Base.put!(processor::JobProcessor, job_id::String)
|
||||||
|
@info "New job received: $job_id"
|
||||||
|
return put!(processor.pending, job_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
function isbusy(processor::JobProcessor)
|
||||||
|
return isready(processor.pending) || isready(processor.processing)
|
||||||
|
end
|
||||||
|
|
||||||
|
function run!(processor::JobProcessor)
|
||||||
|
while true
|
||||||
|
# Check for shutdown signal
|
||||||
|
if isready(processor.shutdown)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wait for a job with timeout
|
||||||
|
if !isready(processor.pending)
|
||||||
|
sleep(0.1)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
# Move job from pending to processing queue
|
||||||
|
job_id = take!(processor.pending)
|
||||||
|
@info "Processing job: $job_id"
|
||||||
|
job_dir = joinpath(basedir, "jobs", job_id)
|
||||||
|
log_path = joinpath(job_dir, "output.log")
|
||||||
|
put!(processor.processing, job_id)
|
||||||
|
|
||||||
|
# Run optimization
|
||||||
|
try
|
||||||
|
open(log_path, "w") do io
|
||||||
|
redirect_stdout(io) do
|
||||||
|
redirect_stderr(io) do
|
||||||
|
json_path = joinpath(job_dir, "input.json.gz")
|
||||||
|
instance = UnitCommitment.read(json_path)
|
||||||
|
model = UnitCommitment.build_model(;
|
||||||
|
instance,
|
||||||
|
optimizer = processor.optimizer,
|
||||||
|
)
|
||||||
|
UnitCommitment.optimize!(model)
|
||||||
|
solution = UnitCommitment.solution(model)
|
||||||
|
return UnitCommitment.write(
|
||||||
|
joinpath(job_dir, "output.json"),
|
||||||
|
solution,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove job from processing queue
|
||||||
|
take!(processor.processing)
|
||||||
|
catch e
|
||||||
|
open(log_path, "a") do io
|
||||||
|
println(io, "\nError: ", e)
|
||||||
|
println(io, "\nStacktrace:")
|
||||||
|
return Base.show_backtrace(io, catch_backtrace())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function start(processor::JobProcessor)
|
||||||
|
processor.worker_task = @async run!(processor)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
function stop(processor::JobProcessor)
|
||||||
|
# Signal worker to stop
|
||||||
|
put!(processor.shutdown, true)
|
||||||
|
|
||||||
|
# Wait for worker to finish
|
||||||
|
if processor.worker_task !== nothing
|
||||||
|
try
|
||||||
|
wait(processor.worker_task)
|
||||||
|
catch
|
||||||
|
# Worker may have already exited
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
export JobProcessor, start, stop, put!, isbusy
|
||||||
73
web/backend/src/server.jl
Normal file
73
web/backend/src/server.jl
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using HTTP
|
||||||
|
using Random
|
||||||
|
using JSON
|
||||||
|
using CodecZlib
|
||||||
|
using UnitCommitment
|
||||||
|
|
||||||
|
struct ServerHandle
|
||||||
|
server::HTTP.Server
|
||||||
|
processor::JobProcessor
|
||||||
|
end
|
||||||
|
|
||||||
|
function submit(req, processor::JobProcessor)
|
||||||
|
# Check if request body is empty
|
||||||
|
compressed_body = HTTP.payload(req)
|
||||||
|
if isempty(compressed_body)
|
||||||
|
return HTTP.Response(400, "Error: No file provided")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate compressed JSON by decompressing and parsing
|
||||||
|
try
|
||||||
|
decompressed_data = transcode(GzipDecompressor, compressed_body)
|
||||||
|
JSON.parse(String(decompressed_data))
|
||||||
|
catch e
|
||||||
|
return HTTP.Response(400, "Error: Invalid compressed JSON")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate random job ID (lowercase letters and numbers)
|
||||||
|
job_id = randstring(['a':'z'; '0':'9'], 16)
|
||||||
|
|
||||||
|
# Create job directory
|
||||||
|
job_dir = joinpath(basedir, "jobs", job_id)
|
||||||
|
mkpath(job_dir)
|
||||||
|
|
||||||
|
# Save input file
|
||||||
|
json_path = joinpath(job_dir, "input.json.gz")
|
||||||
|
write(json_path, compressed_body)
|
||||||
|
|
||||||
|
# Add job to queue
|
||||||
|
put!(processor, job_id)
|
||||||
|
|
||||||
|
# Return job ID as JSON
|
||||||
|
response_body = JSON.json(Dict("job_id" => job_id))
|
||||||
|
return HTTP.Response(200, response_body)
|
||||||
|
end
|
||||||
|
|
||||||
|
function jobs_view(req)
|
||||||
|
return HTTP.Response(200, "OK")
|
||||||
|
end
|
||||||
|
|
||||||
|
function start_server(port::Int = 8080; optimizer)
|
||||||
|
Random.seed!()
|
||||||
|
|
||||||
|
# Create and start job processor
|
||||||
|
processor = JobProcessor(optimizer = optimizer)
|
||||||
|
start(processor)
|
||||||
|
|
||||||
|
router = HTTP.Router()
|
||||||
|
|
||||||
|
# Register /submit endpoint
|
||||||
|
HTTP.register!(router, "POST", "/submit", req -> submit(req, processor))
|
||||||
|
|
||||||
|
# Register job/*/view endpoint
|
||||||
|
HTTP.register!(router, "GET", "/jobs/*/view", jobs_view)
|
||||||
|
|
||||||
|
server = HTTP.serve!(router, port; verbose = false)
|
||||||
|
return ServerHandle(server, processor)
|
||||||
|
end
|
||||||
|
|
||||||
|
function stop(handle::ServerHandle)
|
||||||
|
stop(handle.processor)
|
||||||
|
close(handle.server)
|
||||||
|
return nothing
|
||||||
|
end
|
||||||
23
web/backend/test/Project.toml
Normal file
23
web/backend/test/Project.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "BackendT"
|
||||||
|
uuid = "27da795e-16fd-43bd-a2ba-f77bdecaf977"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Alinson S. Xavier <git@axavier.org>"]
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
Backend = "948642ed-e3f9-4642-9296-0f1eaf40c938"
|
||||||
|
CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193"
|
||||||
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
|
HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b"
|
||||||
|
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
||||||
|
JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899"
|
||||||
|
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
||||||
|
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
|
||||||
|
|
||||||
|
[compat]
|
||||||
|
CodecZlib = "0.7.8"
|
||||||
|
HTTP = "1.10.19"
|
||||||
|
HiGHS = "1.20.1"
|
||||||
|
JSON = "0.21.4"
|
||||||
|
JuliaFormatter = "2.2.0"
|
||||||
|
Revise = "3.12.0"
|
||||||
|
Test = "1.11.0"
|
||||||
40
web/backend/test/src/BackendT.jl
Normal file
40
web/backend/test/src/BackendT.jl
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||||
|
# Copyright (C) 2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
# Released under the modified BSD license. See COPYING.md for more details.
|
||||||
|
|
||||||
|
module BackendT
|
||||||
|
|
||||||
|
using Test
|
||||||
|
using HTTP
|
||||||
|
using JSON
|
||||||
|
using CodecZlib
|
||||||
|
import Backend
|
||||||
|
import JuliaFormatter
|
||||||
|
using HiGHS
|
||||||
|
|
||||||
|
BASEDIR = dirname(@__FILE__)
|
||||||
|
|
||||||
|
include("jobs_test.jl")
|
||||||
|
include("server_test.jl")
|
||||||
|
|
||||||
|
function fixture(path::String)::String
|
||||||
|
return "$BASEDIR/../fixtures/$path"
|
||||||
|
end
|
||||||
|
|
||||||
|
function runtests()
|
||||||
|
@testset "UCJL Backend" begin
|
||||||
|
server_test_usage()
|
||||||
|
# jobs_test_usage()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
function format()
|
||||||
|
JuliaFormatter.format(BASEDIR, verbose = true)
|
||||||
|
JuliaFormatter.format("$BASEDIR/../../src", verbose = true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
export runtests, format
|
||||||
|
|
||||||
|
end
|
||||||
43
web/backend/test/src/jobs_test.jl
Normal file
43
web/backend/test/src/jobs_test.jl
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||||
|
# Copyright (C) 2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
# Released under the modified BSD license. See COPYING.md for more details.
|
||||||
|
|
||||||
|
using Backend
|
||||||
|
using Test
|
||||||
|
using HiGHS
|
||||||
|
|
||||||
|
function jobs_test_usage()
|
||||||
|
@testset "JobProcessor" begin
|
||||||
|
# Setup job directory
|
||||||
|
job_id = "qwe123"
|
||||||
|
job_dir = joinpath(Backend.basedir, "jobs", job_id)
|
||||||
|
mkpath(job_dir)
|
||||||
|
cp(fixture("case14.json.gz"), joinpath(job_dir, "input.json.gz"))
|
||||||
|
|
||||||
|
try
|
||||||
|
# Create processor with HiGHS optimizer
|
||||||
|
processor = JobProcessor(optimizer = HiGHS.Optimizer)
|
||||||
|
|
||||||
|
# Start the worker
|
||||||
|
start(processor)
|
||||||
|
|
||||||
|
# Push job to queue
|
||||||
|
put!(processor, job_id)
|
||||||
|
|
||||||
|
# Wait until all jobs are processed
|
||||||
|
while isbusy(processor)
|
||||||
|
sleep(0.1)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check that solution file exists
|
||||||
|
output_path = joinpath(job_dir, "output.json")
|
||||||
|
@test isfile(output_path)
|
||||||
|
|
||||||
|
# Stop the worker
|
||||||
|
stop(processor)
|
||||||
|
finally
|
||||||
|
# Cleanup
|
||||||
|
rm(job_dir, recursive = true, force = true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
44
web/backend/test/src/server_test.jl
Normal file
44
web/backend/test/src/server_test.jl
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# UnitCommitment.jl: Optimization Package for Security-Constrained Unit Commitment
|
||||||
|
# Copyright (C) 2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
# Released under the modified BSD license. See COPYING.md for more details.
|
||||||
|
|
||||||
|
const PORT = 32617
|
||||||
|
|
||||||
|
function server_test_usage()
|
||||||
|
server = Backend.start_server(PORT; optimizer = HiGHS.Optimizer)
|
||||||
|
|
||||||
|
# Read the compressed fixture file
|
||||||
|
compressed_data = read(fixture("case14.json.gz"))
|
||||||
|
|
||||||
|
# Submit test case
|
||||||
|
response = HTTP.post(
|
||||||
|
"http://localhost:$PORT/submit",
|
||||||
|
["Content-Type" => "application/gzip"],
|
||||||
|
compressed_data,
|
||||||
|
)
|
||||||
|
@test response.status == 200
|
||||||
|
|
||||||
|
# Check response
|
||||||
|
response_data = JSON.parse(String(response.body))
|
||||||
|
@test haskey(response_data, "job_id")
|
||||||
|
job_id = response_data["job_id"]
|
||||||
|
@test length(job_id) == 16
|
||||||
|
|
||||||
|
# Wait for jobs to finish and stop server
|
||||||
|
sleep(0.1)
|
||||||
|
stop(server)
|
||||||
|
|
||||||
|
# Verify the compressed file was saved correctly
|
||||||
|
job_dir = joinpath(Backend.basedir, "jobs", job_id)
|
||||||
|
saved_input_path = joinpath(job_dir, "input.json.gz")
|
||||||
|
saved_log_path = joinpath(job_dir, "output.log")
|
||||||
|
saved_output_path = joinpath(job_dir, "output.json")
|
||||||
|
@test isfile(saved_input_path)
|
||||||
|
@test isfile(saved_log_path)
|
||||||
|
@test isfile(saved_output_path)
|
||||||
|
saved_data = read(saved_input_path)
|
||||||
|
@test saved_data == compressed_data
|
||||||
|
|
||||||
|
# Clean up: remove the job directory
|
||||||
|
# rm(job_dir, recursive=true)
|
||||||
|
end
|
||||||
7
web/frontend/.dockerignore
Normal file
7
web/frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
1
web/frontend/.env
Normal file
1
web/frontend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
FAST_REFRESH=false
|
||||||
25
web/frontend/.gitignore
vendored
Normal file
25
web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
assets
|
||||||
1
web/frontend/.prettierrc.json
Normal file
1
web/frontend/.prettierrc.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
13
web/frontend/Dockerfile
Normal file
13
web/frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Build Stage
|
||||||
|
FROM node:18-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production Stage
|
||||||
|
FROM nginx:stable-alpine AS production
|
||||||
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
17746
web/frontend/package-lock.json
generated
Normal file
17746
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
web/frontend/package.json
Normal file
65
web/frontend/package.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"@types/papaparse": "^5.3.16",
|
||||||
|
"@types/react": "^19.1.3",
|
||||||
|
"@types/react-dom": "^19.1.3",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"pako": "^2.1.0",
|
||||||
|
"papaparse": "^5.5.2",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-scripts": "^5.0.1",
|
||||||
|
"tabulator-tables": "^6.3.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/tabulator-tables": "^6.2.6",
|
||||||
|
"prettier": "3.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/frontend/public/favicon.ico
Normal file
BIN
web/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
43
web/frontend/public/index.html
Normal file
43
web/frontend/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="UnitCommitment.jl Case Builder" />
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>Case Builder - UnitCommitment.jl</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--site-max-width: 1500px;
|
||||||
|
--site-min-width: 900px;
|
||||||
|
--box-border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
--box-shadow: 0px 2px 4px -3px rgba(0, 0, 0, 0.2);
|
||||||
|
--border-radius: 4px;
|
||||||
|
--primary: #0d6efd;
|
||||||
|
--contrast-100: #202020;
|
||||||
|
--contrast-80: #606060;
|
||||||
|
--contrast-60: #909090;
|
||||||
|
--contrast-20: #d6d6d6;
|
||||||
|
--contrast-10: #f6f6f6;
|
||||||
|
--contrast-0: #fefefe;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: var(--contrast-10);
|
||||||
|
padding-bottom: 36px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
web/frontend/public/logo192.png
Normal file
BIN
web/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/frontend/public/logo512.png
Normal file
BIN
web/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
web/frontend/public/manifest.json
Normal file
25
web/frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
web/frontend/public/robots.txt
Normal file
3
web/frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
48
web/frontend/src/components/CaseBuilder/Buses.test.ts
Normal file
48
web/frontend/src/components/CaseBuilder/Buses.test.ts
Normal file
@@ -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/Data/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)`);
|
||||||
|
});
|
||||||
150
web/frontend/src/components/CaseBuilder/Buses.tsx
Normal file
150
web/frontend/src/components/CaseBuilder/Buses.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* 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 SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { offerDownload } from "../Common/io";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { ValidationError } from "../../core/Data/validate";
|
||||||
|
import DataTable, {
|
||||||
|
ColumnSpec,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
import {
|
||||||
|
changeBusData,
|
||||||
|
createBus,
|
||||||
|
deleteBus,
|
||||||
|
renameBus,
|
||||||
|
} from "../../core/Operations/busOps";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
|
||||||
|
export const BusesColumnSpec: ColumnSpec[] = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
type: "string",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Load (MW)",
|
||||||
|
type: "number[T]",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const generateBusesData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, BusesColumnSpec);
|
||||||
|
const data = generateTableData(scenario.Buses, BusesColumnSpec, scenario);
|
||||||
|
return [data, columns];
|
||||||
|
};
|
||||||
|
|
||||||
|
function BusesComponent(props: CaseBuilderSectionProps) {
|
||||||
|
const fileUploadElem = useRef<FileUploadElement>(null);
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
const [data, columns] = generateBusesData(props.scenario);
|
||||||
|
const csvContents = generateCsv(data, columns);
|
||||||
|
offerDownload(csvContents, "text/csv", "buses.csv");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = () => {
|
||||||
|
fileUploadElem.current!.showFilePicker((csvContents: any) => {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Buses">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generateBusesData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BusesComponent;
|
||||||
145
web/frontend/src/components/CaseBuilder/CaseBuilder.tsx
Normal file
145
web/frontend/src/components/CaseBuilder/CaseBuilder.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
* 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 Header from "./Header";
|
||||||
|
import Parameters from "./Parameters";
|
||||||
|
import BusesComponent from "./Buses";
|
||||||
|
import { BLANK_SCENARIO } from "../../core/Data/fixtures";
|
||||||
|
|
||||||
|
import "tabulator-tables/dist/css/tabulator.min.css";
|
||||||
|
import "../Common/Forms/Tables.css";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
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";
|
||||||
|
import TransmissionLinesComponent from "./TransmissionLines";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
import StorageComponent from "./StorageUnits";
|
||||||
|
import PriceSensitiveLoadsComponent from "./Psload";
|
||||||
|
|
||||||
|
export interface CaseBuilderSectionProps {
|
||||||
|
scenario: UnitCommitmentScenario;
|
||||||
|
onDataChanged: (scenario: UnitCommitmentScenario) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaseBuilder = () => {
|
||||||
|
const [scenario, setScenario] = useState(() => {
|
||||||
|
const savedScenario = localStorage.getItem("scenario");
|
||||||
|
if (!savedScenario) return BLANK_SCENARIO;
|
||||||
|
const [processedScenario, err] = preprocess(JSON.parse(savedScenario));
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
return BLANK_SCENARIO;
|
||||||
|
}
|
||||||
|
return processedScenario!!;
|
||||||
|
});
|
||||||
|
const [undoStack, setUndoStack] = useState<UnitCommitmentScenario[]>([]);
|
||||||
|
const [toastMessage, setToastMessage] = useState<string>("");
|
||||||
|
|
||||||
|
const setAndSaveScenario = (
|
||||||
|
newScenario: UnitCommitmentScenario,
|
||||||
|
updateUndoStack = true,
|
||||||
|
) => {
|
||||||
|
if (updateUndoStack) {
|
||||||
|
const newUndoStack = [...undoStack, scenario];
|
||||||
|
if (newUndoStack.length > 25) {
|
||||||
|
newUndoStack.splice(0, newUndoStack.length - 25);
|
||||||
|
}
|
||||||
|
setUndoStack(newUndoStack);
|
||||||
|
}
|
||||||
|
setScenario(newScenario);
|
||||||
|
localStorage.setItem("scenario", JSON.stringify(newScenario));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
setAndSaveScenario(BLANK_SCENARIO);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
offerDownload(
|
||||||
|
JSON.stringify(scenario, null, 2),
|
||||||
|
"application/json",
|
||||||
|
"case.json",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataChanged = (newScenario: UnitCommitmentScenario) => {
|
||||||
|
setAndSaveScenario(newScenario);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoad = (data: any) => {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
const [scenario, err] = preprocess(json);
|
||||||
|
if (err) {
|
||||||
|
setToastMessage(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAndSaveScenario(scenario!);
|
||||||
|
setToastMessage("Data loaded successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUndo = () => {
|
||||||
|
if (undoStack.length === 0) return;
|
||||||
|
setUndoStack(undoStack.slice(0, -1));
|
||||||
|
setAndSaveScenario(undoStack[undoStack.length - 1]!, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header
|
||||||
|
onClear={onClear}
|
||||||
|
onSave={onSave}
|
||||||
|
onLoad={onLoad}
|
||||||
|
onUndo={onUndo}
|
||||||
|
/>
|
||||||
|
<div className="content">
|
||||||
|
<Parameters
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<BusesComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<ThermalUnitsComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<ProfiledUnitsComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<StorageComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<PriceSensitiveLoadsComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<TransmissionLinesComponent
|
||||||
|
scenario={scenario}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
onError={setToastMessage}
|
||||||
|
/>
|
||||||
|
<Toast message={toastMessage} />
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CaseBuilder;
|
||||||
14
web/frontend/src/components/CaseBuilder/Footer.module.css
Normal file
14
web/frontend/src/components/CaseBuilder/Footer.module.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.Footer {
|
||||||
|
background-color: #333;
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
19
web/frontend/src/components/CaseBuilder/Footer.tsx
Normal file
19
web/frontend/src/components/CaseBuilder/Footer.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./Footer.module.css";
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
return (
|
||||||
|
<div className={styles.Footer}>
|
||||||
|
UnitCommitment.jl: Optimization Package for Security-Constrained Unit
|
||||||
|
Commitment <br />
|
||||||
|
Copyright (C) 2020-2025, UChicago Argonne, LLC. All rights reserved.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
41
web/frontend/src/components/CaseBuilder/Header.module.css
Normal file
41
web/frontend/src/components/CaseBuilder/Header.module.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.HeaderBox {
|
||||||
|
background-color: var(--contrast-0);
|
||||||
|
border-bottom: var(--box-border);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.HeaderContent {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--site-max-width);
|
||||||
|
min-width: var(--site-min-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.HeaderContent h1,
|
||||||
|
h2 {
|
||||||
|
color: var(--contrast-100);
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 48px;
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.HeaderContent h2 {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--contrast-80);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContainer {
|
||||||
|
float: right;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
46
web/frontend/src/components/CaseBuilder/Header.tsx
Normal file
46
web/frontend/src/components/CaseBuilder/Header.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./Header.module.css";
|
||||||
|
import SiteHeaderButton from "../Common/Buttons/SiteHeaderButton";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onClear: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onLoad: (data: UnitCommitmentScenario) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header(props: HeaderProps) {
|
||||||
|
const fileElem = useRef<FileUploadElement>(null);
|
||||||
|
|
||||||
|
function onLoad() {
|
||||||
|
fileElem.current!.showFilePicker((data: any) => {
|
||||||
|
props.onLoad(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.HeaderBox}>
|
||||||
|
<div className={styles.HeaderContent}>
|
||||||
|
<h1>UnitCommitment.jl</h1>
|
||||||
|
<h2>Case Builder</h2>
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<SiteHeaderButton title="Undo" onClick={props.onUndo} />
|
||||||
|
<SiteHeaderButton title="Clear" onClick={props.onClear} />
|
||||||
|
<SiteHeaderButton title="Load" onClick={onLoad} />
|
||||||
|
<SiteHeaderButton title="Save" onClick={props.onSave} />
|
||||||
|
</div>
|
||||||
|
<FileUploadElement ref={fileElem} accept=".json,.json.gz" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
71
web/frontend/src/components/CaseBuilder/Parameters.tsx
Normal file
71
web/frontend/src/components/CaseBuilder/Parameters.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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 SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import Form from "../Common/Forms/Form";
|
||||||
|
import TextInputRow from "../Common/Forms/TextInputRow";
|
||||||
|
import {
|
||||||
|
changeParameter,
|
||||||
|
changeTimeHorizon,
|
||||||
|
changeTimeStep,
|
||||||
|
} from "../../core/Operations/parameterOps";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
|
||||||
|
interface ParametersProps {
|
||||||
|
scenario: UnitCommitmentScenario;
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Parameters"></SectionHeader>
|
||||||
|
<Form>
|
||||||
|
<TextInputRow
|
||||||
|
label="Time horizon"
|
||||||
|
unit="h"
|
||||||
|
tooltip="Length of the planning horizon (in hours)."
|
||||||
|
initialValue={`${props.scenario.Parameters["Time horizon (h)"]}`}
|
||||||
|
onChange={(v) => onDataChanged("Time horizon (h)", v)}
|
||||||
|
/>
|
||||||
|
<TextInputRow
|
||||||
|
label="Time step"
|
||||||
|
unit="min"
|
||||||
|
tooltip="Length of each time step (in minutes). Must be a divisor of 60 (e.g. 60, 30, 20, 15, etc)."
|
||||||
|
initialValue={`${props.scenario.Parameters["Time step (min)"]}`}
|
||||||
|
onChange={(v) => onDataChanged("Time step (min)", v)}
|
||||||
|
/>
|
||||||
|
<TextInputRow
|
||||||
|
label="Power balance penalty"
|
||||||
|
unit="$/MW"
|
||||||
|
tooltip="Penalty for system-wide shortage or surplus in production (in /MW). This is charged per time step. For example, if there is a shortage of 1 MW for three time steps, three times this amount will be charged."
|
||||||
|
initialValue={`${props.scenario.Parameters["Power balance penalty ($/MW)"]}`}
|
||||||
|
onChange={(v) => onDataChanged("Power balance penalty ($/MW)", v)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Parameters;
|
||||||
111
web/frontend/src/components/CaseBuilder/ProfiledUnits.test.ts
Normal file
111
web/frontend/src/components/CaseBuilder/ProfiledUnits.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
generateTableColumns,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import {
|
||||||
|
parseProfiledUnitsCsv,
|
||||||
|
ProfiledUnitsColumnSpec,
|
||||||
|
} from "./ProfiledUnits";
|
||||||
|
import { TEST_DATA_1 } from "../../core/Data/fixtures.test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import {
|
||||||
|
getProfiledGenerators,
|
||||||
|
getThermalGenerators,
|
||||||
|
} from "../../core/Data/types";
|
||||||
|
|
||||||
|
test("parse CSV", () => {
|
||||||
|
const csvContents =
|
||||||
|
"Name,Bus,Cost ($/MW),Maximum power (MW) 00:00,Maximum power (MW) 01:00," +
|
||||||
|
"Maximum power (MW) 02:00,Maximum power (MW) 03:00," +
|
||||||
|
"Maximum power (MW) 04:00,Minimum power (MW) 00:00," +
|
||||||
|
"Minimum power (MW) 01:00,Minimum power (MW) 02:00," +
|
||||||
|
"Minimum power (MW) 03:00,Minimum power (MW) 04:00\n" +
|
||||||
|
"pu1,b1,50,260.25384545,72.89148068,377.17886108,336.66732361," +
|
||||||
|
"376.82781758,52.05076909,14.57829614,75.43577222,67.33346472,75.36556352\n" +
|
||||||
|
"pu2,b1,0,0,0,0,0,0,0,0,0,0,0";
|
||||||
|
const [scenario, err] = parseProfiledUnitsCsv(csvContents, TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
const thermalGens = getThermalGenerators(scenario);
|
||||||
|
const profGens = getProfiledGenerators(scenario);
|
||||||
|
assert.equal(Object.keys(thermalGens).length, 1);
|
||||||
|
assert.equal(Object.keys(profGens).length, 2);
|
||||||
|
|
||||||
|
assert.deepEqual(profGens, {
|
||||||
|
pu1: {
|
||||||
|
Bus: "b1",
|
||||||
|
"Minimum power (MW)": [
|
||||||
|
52.05076909, 14.57829614, 75.43577222, 67.33346472, 75.36556352,
|
||||||
|
],
|
||||||
|
"Maximum power (MW)": [
|
||||||
|
260.25384545, 72.89148068, 377.17886108, 336.66732361, 376.82781758,
|
||||||
|
],
|
||||||
|
"Cost ($/MW)": 50.0,
|
||||||
|
Type: "Profiled",
|
||||||
|
},
|
||||||
|
pu2: {
|
||||||
|
Bus: "b1",
|
||||||
|
"Minimum power (MW)": [0, 0, 0, 0, 0],
|
||||||
|
"Maximum power (MW)": [0, 0, 0, 0, 0],
|
||||||
|
"Cost ($/MW)": 0.0,
|
||||||
|
Type: "Profiled",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse CSV with invalid bus", () => {
|
||||||
|
const csvContents =
|
||||||
|
"Name,Bus,Cost ($/MW),Maximum power (MW) 00:00,Maximum power (MW) 01:00," +
|
||||||
|
"Maximum power (MW) 02:00,Maximum power (MW) 03:00," +
|
||||||
|
"Maximum power (MW) 04:00,Minimum power (MW) 00:00," +
|
||||||
|
"Minimum power (MW) 01:00,Minimum power (MW) 02:00," +
|
||||||
|
"Minimum power (MW) 03:00,Minimum power (MW) 04:00\n" +
|
||||||
|
"pu1,b99,50,260.25384545,72.89148068,377.17886108,336.66732361," +
|
||||||
|
"376.82781758,52.05076909,14.57829614,75.43577222,67.33346472,75.36556352\n" +
|
||||||
|
"pu2,b1,0,0,0,0,0,0,0,0,0,0,0";
|
||||||
|
const [, err] = parseCsv(csvContents, ProfiledUnitsColumnSpec, TEST_DATA_1);
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, 'Bus "b99" does not exist (row 1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generateTableColumns", () => {
|
||||||
|
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: 75,
|
||||||
|
resizable: false,
|
||||||
|
title: "00:00",
|
||||||
|
});
|
||||||
|
});
|
||||||
197
web/frontend/src/components/CaseBuilder/ProfiledUnits.tsx
Normal file
197
web/frontend/src/components/CaseBuilder/ProfiledUnits.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
* 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 SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import DataTable, {
|
||||||
|
ColumnSpec,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
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/Data/validate";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import {
|
||||||
|
getProfiledGenerators,
|
||||||
|
getThermalGenerators,
|
||||||
|
UnitCommitmentScenario,
|
||||||
|
} from "../../core/Data/types";
|
||||||
|
|
||||||
|
export const ProfiledUnitsColumnSpec: ColumnSpec[] = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
type: "string",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bus",
|
||||||
|
type: "busRef",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cost ($/MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maximum power (MW)",
|
||||||
|
type: "number[T]",
|
||||||
|
width: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum power (MW)",
|
||||||
|
type: "number[T]",
|
||||||
|
width: 75,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateProfiledUnitsData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, ProfiledUnitsColumnSpec);
|
||||||
|
const data = generateTableData(
|
||||||
|
getProfiledGenerators(scenario),
|
||||||
|
ProfiledUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
return [data, columns];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseProfiledUnitsCsv = (
|
||||||
|
csvContents: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [profGens, err] = parseCsv(
|
||||||
|
csvContents,
|
||||||
|
ProfiledUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
|
||||||
|
// Process imported generators
|
||||||
|
for (const gen in profGens) {
|
||||||
|
profGens[gen]["Type"] = "Profiled";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing data
|
||||||
|
const thermalGens = getThermalGenerators(scenario);
|
||||||
|
const newScenario = {
|
||||||
|
...scenario,
|
||||||
|
Generators: { ...thermalGens, ...profGens },
|
||||||
|
};
|
||||||
|
return [newScenario, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfiledUnitsComponent = (props: CaseBuilderSectionProps) => {
|
||||||
|
const fileUploadElem = useRef<FileUploadElement>(null);
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
const [data, columns] = generateProfiledUnitsData(props.scenario);
|
||||||
|
const csvContents = generateCsv(data, columns);
|
||||||
|
offerDownload(csvContents, "text/csv", "profiled_units.csv");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = () => {
|
||||||
|
fileUploadElem.current!.showFilePicker((csv: any) => {
|
||||||
|
const [newScenario, err] = parseProfiledUnitsCsv(csv, props.scenario);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const [newScenario, err] = createProfiledUnit(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] = 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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Profiled units">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generateProfiledUnitsData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfiledUnitsComponent;
|
||||||
175
web/frontend/src/components/CaseBuilder/Psload.tsx
Normal file
175
web/frontend/src/components/CaseBuilder/Psload.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { ValidationError } from "../../core/Data/validate";
|
||||||
|
import SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
import {
|
||||||
|
changePriceSensitiveLoadData,
|
||||||
|
createPriceSensitiveLoad,
|
||||||
|
deletePriceSensitiveLoad,
|
||||||
|
renamePriceSensitiveLoad,
|
||||||
|
} from "../../core/Operations/psloadOps";
|
||||||
|
import { offerDownload } from "../Common/io";
|
||||||
|
|
||||||
|
export const PriceSensitiveLoadsColumnSpec: ColumnSpec[] = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
type: "string",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bus",
|
||||||
|
type: "busRef",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Revenue ($/MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Demand (MW)",
|
||||||
|
type: "number[T]",
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const generatePriceSensitiveLoadsData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, PriceSensitiveLoadsColumnSpec);
|
||||||
|
const data = generateTableData(
|
||||||
|
scenario["Price-sensitive loads"],
|
||||||
|
PriceSensitiveLoadsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
return [data, columns];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PriceSensitiveLoadsComponent = (props: CaseBuilderSectionProps) => {
|
||||||
|
const fileUploadElem = useRef<FileUploadElement>(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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Price-sensitive loads">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generatePriceSensitiveLoadsData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceSensitiveLoadsComponent;
|
||||||
235
web/frontend/src/components/CaseBuilder/StorageUnits.tsx
Normal file
235
web/frontend/src/components/CaseBuilder/StorageUnits.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { ValidationError } from "../../core/Data/validate";
|
||||||
|
import SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
import {
|
||||||
|
changeStorageUnitData,
|
||||||
|
createStorageUnit,
|
||||||
|
deleteStorageUnit,
|
||||||
|
renameStorageUnit,
|
||||||
|
} from "../../core/Operations/storageOps";
|
||||||
|
import { offerDownload } from "../Common/io";
|
||||||
|
|
||||||
|
export const StorageUnitsColumnSpec: ColumnSpec[] = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
type: "string",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bus",
|
||||||
|
type: "busRef",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum level (MWh)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maximum level (MWh)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Charge cost ($/MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Discharge cost ($/MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Charge efficiency",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Discharge efficiency",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Loss factor",
|
||||||
|
type: "number",
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum charge rate (MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maximum charge rate (MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum discharge rate (MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maximum discharge rate (MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Initial level (MWh)",
|
||||||
|
type: "number",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Last period minimum level (MWh)",
|
||||||
|
type: "number",
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Last period maximum level (MWh)",
|
||||||
|
type: "number",
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const generateStorageUnitsData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, StorageUnitsColumnSpec);
|
||||||
|
const data = generateTableData(
|
||||||
|
scenario["Storage units"],
|
||||||
|
StorageUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
return [data, columns];
|
||||||
|
};
|
||||||
|
|
||||||
|
const StorageUnitsComponent = (props: CaseBuilderSectionProps) => {
|
||||||
|
const fileUploadElem = useRef<FileUploadElement>(null);
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
const [data, columns] = generateStorageUnitsData(props.scenario);
|
||||||
|
const csvContents = generateCsv(data, columns);
|
||||||
|
offerDownload(csvContents, "text/csv", "storage_units.csv");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = () => {
|
||||||
|
fileUploadElem.current!.showFilePicker((csv: any) => {
|
||||||
|
// Parse provided CSV file
|
||||||
|
const [storageUnits, err] = parseCsv(
|
||||||
|
csv,
|
||||||
|
StorageUnitsColumnSpec,
|
||||||
|
props.scenario,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new scenario
|
||||||
|
props.onDataChanged({
|
||||||
|
...props.scenario,
|
||||||
|
"Storage units": storageUnits,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const [newScenario, err] = createStorageUnit(props.scenario);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (name: string): ValidationError | null => {
|
||||||
|
const newScenario = deleteStorageUnit(name, props.scenario);
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataChanged = (
|
||||||
|
name: string,
|
||||||
|
field: string,
|
||||||
|
newValue: string,
|
||||||
|
): ValidationError | null => {
|
||||||
|
const [newScenario, err] = changeStorageUnitData(
|
||||||
|
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] = renameStorageUnit(
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
props.scenario,
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Storage units">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generateStorageUnitsData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageUnitsComponent;
|
||||||
209
web/frontend/src/components/CaseBuilder/ThermalUnits.test.ts
Normal file
209
web/frontend/src/components/CaseBuilder/ThermalUnits.test.ts
Normal file
@@ -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/Data/fixtures.test";
|
||||||
|
import {
|
||||||
|
generateThermalUnitsData,
|
||||||
|
parseThermalUnitsCsv,
|
||||||
|
ThermalUnitsColumnSpec,
|
||||||
|
} from "./ThermalUnits";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import {
|
||||||
|
getProfiledGenerators,
|
||||||
|
getThermalGenerators,
|
||||||
|
} from "../../core/Data/types";
|
||||||
|
|
||||||
|
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: 80,
|
||||||
|
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)');
|
||||||
|
});
|
||||||
251
web/frontend/src/components/CaseBuilder/ThermalUnits.tsx
Normal file
251
web/frontend/src/components/CaseBuilder/ThermalUnits.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { ValidationError } from "../../core/Data/validate";
|
||||||
|
import SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
import { offerDownload } from "../Common/io";
|
||||||
|
import {
|
||||||
|
changeThermalUnitData,
|
||||||
|
createThermalUnit,
|
||||||
|
deleteGenerator,
|
||||||
|
renameGenerator,
|
||||||
|
} from "../../core/Operations/generatorOps";
|
||||||
|
import {
|
||||||
|
getProfiledGenerators,
|
||||||
|
getThermalGenerators,
|
||||||
|
UnitCommitmentScenario,
|
||||||
|
} from "../../core/Data/types";
|
||||||
|
|
||||||
|
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: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Production cost curve ($)",
|
||||||
|
type: "number[N]",
|
||||||
|
length: 10,
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Startup costs ($)",
|
||||||
|
type: "number[N]",
|
||||||
|
length: 5,
|
||||||
|
width: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Startup delays (h)",
|
||||||
|
type: "number[N]",
|
||||||
|
length: 5,
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum uptime (h)",
|
||||||
|
type: "number",
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Minimum 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const generateThermalUnitsData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, ThermalUnitsColumnSpec);
|
||||||
|
const data = generateTableData(
|
||||||
|
getThermalGenerators(scenario),
|
||||||
|
ThermalUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
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<FileUploadElement>(null);
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
const [data, columns] = generateThermalUnitsData(props.scenario);
|
||||||
|
const csvContents = generateCsv(data, columns);
|
||||||
|
offerDownload(csvContents, "text/csv", "thermal_units.csv");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = () => {
|
||||||
|
fileUploadElem.current!.showFilePicker((csv: any) => {
|
||||||
|
const [newScenario, err] = parseThermalUnitsCsv(csv, props.scenario);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Thermal units">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generateThermalUnitsData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThermalUnitsComponent;
|
||||||
203
web/frontend/src/components/CaseBuilder/TransmissionLines.tsx
Normal file
203
web/frontend/src/components/CaseBuilder/TransmissionLines.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* 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 SectionHeader from "../Common/SectionHeader/SectionHeader";
|
||||||
|
import SectionButton from "../Common/Buttons/SectionButton";
|
||||||
|
import {
|
||||||
|
faDownload,
|
||||||
|
faPlus,
|
||||||
|
faUpload,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import DataTable, {
|
||||||
|
ColumnSpec,
|
||||||
|
generateCsv,
|
||||||
|
generateTableColumns,
|
||||||
|
generateTableData,
|
||||||
|
parseCsv,
|
||||||
|
} from "../Common/Forms/DataTable";
|
||||||
|
import { ColumnDefinition } from "tabulator-tables";
|
||||||
|
import FileUploadElement from "../Common/Buttons/FileUploadElement";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { ValidationError } from "../../core/Data/validate";
|
||||||
|
import { CaseBuilderSectionProps } from "./CaseBuilder";
|
||||||
|
import {
|
||||||
|
changeTransmissionLineData,
|
||||||
|
createTransmissionLine,
|
||||||
|
deleteTransmissionLine,
|
||||||
|
rebuildContingencies,
|
||||||
|
renameTransmissionLine,
|
||||||
|
} from "../../core/Operations/transmissionOps";
|
||||||
|
import { offerDownload } from "../Common/io";
|
||||||
|
import { UnitCommitmentScenario } from "../../core/Data/types";
|
||||||
|
|
||||||
|
export const TransmissionLinesColumnSpec: ColumnSpec[] = [
|
||||||
|
{
|
||||||
|
title: "Name",
|
||||||
|
type: "string",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Source bus",
|
||||||
|
type: "busRef",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Target bus",
|
||||||
|
type: "busRef",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Susceptance (S)",
|
||||||
|
type: "number",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Normal flow limit (MW)",
|
||||||
|
type: "number?",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Emergency flow limit (MW)",
|
||||||
|
type: "number?",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Flow limit penalty ($/MW)",
|
||||||
|
type: "number",
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contingency?",
|
||||||
|
type: "lineContingency",
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateTransmissionLinesData = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [any[], ColumnDefinition[]] => {
|
||||||
|
const columns = generateTableColumns(scenario, TransmissionLinesColumnSpec);
|
||||||
|
const data = generateTableData(
|
||||||
|
scenario["Transmission lines"],
|
||||||
|
TransmissionLinesColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
return [data, columns];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TransmissionLinesComponent = (props: CaseBuilderSectionProps) => {
|
||||||
|
const fileUploadElem = useRef<FileUploadElement>(null);
|
||||||
|
|
||||||
|
const onDownload = () => {
|
||||||
|
const [data, columns] = generateTransmissionLinesData(props.scenario);
|
||||||
|
const csvContents = generateCsv(data, columns);
|
||||||
|
offerDownload(csvContents, "text/csv", "transmission.csv");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = () => {
|
||||||
|
fileUploadElem.current!.showFilePicker((csv: any) => {
|
||||||
|
// Parse the CSV data
|
||||||
|
const [newLines, err] = parseCsv(
|
||||||
|
csv,
|
||||||
|
TransmissionLinesColumnSpec,
|
||||||
|
props.scenario,
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove contingency field from line and rebuild the contingencies section
|
||||||
|
const lineContingencies = new Set<String>();
|
||||||
|
Object.entries(newLines).forEach(([lineName, line]: [string, any]) => {
|
||||||
|
if (line["Contingency?"]) lineContingencies.add(lineName);
|
||||||
|
delete line["Contingency?"];
|
||||||
|
});
|
||||||
|
const contingencies = rebuildContingencies(lineContingencies);
|
||||||
|
|
||||||
|
const newScenario = {
|
||||||
|
...props.scenario,
|
||||||
|
"Transmission lines": newLines,
|
||||||
|
Contingencies: contingencies,
|
||||||
|
};
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const [newScenario, err] = createTransmissionLine(props.scenario);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (name: string): ValidationError | null => {
|
||||||
|
const newScenario = deleteTransmissionLine(name, props.scenario);
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataChanged = (
|
||||||
|
name: string,
|
||||||
|
field: string,
|
||||||
|
newValue: string,
|
||||||
|
): ValidationError | null => {
|
||||||
|
const [newScenario, err] = changeTransmissionLineData(
|
||||||
|
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] = renameTransmissionLine(
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
props.scenario,
|
||||||
|
);
|
||||||
|
if (err) {
|
||||||
|
props.onError(err.message);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
props.onDataChanged(newScenario);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader title="Transmission lines">
|
||||||
|
<SectionButton icon={faPlus} tooltip="Add" onClick={onAdd} />
|
||||||
|
<SectionButton
|
||||||
|
icon={faDownload}
|
||||||
|
tooltip="Download"
|
||||||
|
onClick={onDownload}
|
||||||
|
/>
|
||||||
|
<SectionButton icon={faUpload} tooltip="Upload" onClick={onUpload} />
|
||||||
|
</SectionHeader>
|
||||||
|
<DataTable
|
||||||
|
onRowDeleted={onDelete}
|
||||||
|
onRowRenamed={onRename}
|
||||||
|
onDataChanged={onDataChanged}
|
||||||
|
generateData={() => generateTransmissionLinesData(props.scenario)}
|
||||||
|
/>
|
||||||
|
<FileUploadElement ref={fileUploadElem} accept=".csv" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransmissionLinesComponent;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* 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 pako from "pako";
|
||||||
|
import React, { Component } from "react";
|
||||||
|
|
||||||
|
class FileUploadElement extends Component<any> {
|
||||||
|
private inputRef = React.createRef<HTMLInputElement>();
|
||||||
|
private callback: (data: any) => void = () => {};
|
||||||
|
|
||||||
|
showFilePicker = (callback: (data: any) => void) => {
|
||||||
|
this.callback = callback;
|
||||||
|
this.inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files![0]!;
|
||||||
|
let isCompressed = file.name.endsWith(".gz");
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
let content = e.target?.result;
|
||||||
|
|
||||||
|
if (isCompressed) {
|
||||||
|
const compressed = new Uint8Array(content as ArrayBuffer);
|
||||||
|
const decompressed = pako.inflate(compressed);
|
||||||
|
content = new TextDecoder().decode(decompressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callback(content as string);
|
||||||
|
this.callback = () => {};
|
||||||
|
};
|
||||||
|
if (isCompressed) {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={this.inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={this.props.accept}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={this.onFileSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileUploadElement;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
visibility: hidden;
|
||||||
|
background-color: var(--contrast-80);
|
||||||
|
color: var(--contrast-10);
|
||||||
|
opacity: 0;
|
||||||
|
width: 250px;
|
||||||
|
margin-top: 36px;
|
||||||
|
margin-left: -250px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
line-height: 20px;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--contrast-60);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 8px 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.HelpButton {
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.HelpButton:hover .tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 100%;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
22
web/frontend/src/components/Common/Buttons/HelpButton.tsx
Normal file
22
web/frontend/src/components/Common/Buttons/HelpButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./HelpButton.module.css";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
|
||||||
|
function HelpButton({ text }: { text: String }) {
|
||||||
|
return (
|
||||||
|
<button className={styles.HelpButton}>
|
||||||
|
<span className={styles.tooltip}>{text}</span>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<FontAwesomeIcon icon={faCircleQuestion} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HelpButton;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.SectionButton {
|
||||||
|
height: 48px;
|
||||||
|
width: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 8px 0 8px 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--contrast-60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SectionButton:hover {
|
||||||
|
color: var(--contrast-100);
|
||||||
|
background-color: var(--contrast-20);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SectionButton:active {
|
||||||
|
background-color: var(--contrast-60);
|
||||||
|
}
|
||||||
29
web/frontend/src/components/Common/Buttons/SectionButton.tsx
Normal file
29
web/frontend/src/components/Common/Buttons/SectionButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* 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 { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import styles from "./SectionButton.module.css";
|
||||||
|
|
||||||
|
interface SectionButtonProps {
|
||||||
|
icon: IconDefinition;
|
||||||
|
tooltip: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionButton(props: SectionButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.SectionButton}
|
||||||
|
title={props.tooltip}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={props.icon} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionButton;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.SiteHeaderButton {
|
||||||
|
padding: 6px 24px;
|
||||||
|
margin: 0 0 0 8px;
|
||||||
|
line-height: 24px;
|
||||||
|
border: var(--box-border);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--contrast-80);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
background: linear-gradient(var(--contrast-0) 25%, var(--contrast-10) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SiteHeaderButton:hover {
|
||||||
|
background: rgb(245, 245, 245);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SiteHeaderButton:active {
|
||||||
|
background: rgba(220, 220, 220);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./SiteHeaderButton.module.css";
|
||||||
|
|
||||||
|
function SiteHeaderButton({
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={styles.SiteHeaderButton} onClick={onClick}>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SiteHeaderButton;
|
||||||
531
web/frontend/src/components/Common/Forms/DataTable.tsx
Normal file
531
web/frontend/src/components/Common/Forms/DataTable.tsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
/*
|
||||||
|
* 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 { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
CellComponent,
|
||||||
|
ColumnDefinition,
|
||||||
|
TabulatorFull as Tabulator,
|
||||||
|
} from "tabulator-tables";
|
||||||
|
import { ValidationError } from "../../../core/Data/validate";
|
||||||
|
import Papa from "papaparse";
|
||||||
|
import {
|
||||||
|
parseBool,
|
||||||
|
parseNullableNumber,
|
||||||
|
parseNumber,
|
||||||
|
} from "../../../core/Operations/commonOps";
|
||||||
|
import { UnitCommitmentScenario } from "../../../core/Data/types";
|
||||||
|
import { getContingencyTransmissionLines } from "../../../core/Operations/transmissionOps";
|
||||||
|
|
||||||
|
export interface ColumnSpec {
|
||||||
|
title: string;
|
||||||
|
type:
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "number?"
|
||||||
|
| "number[N]"
|
||||||
|
| "number[T]"
|
||||||
|
| "busRef"
|
||||||
|
| "boolean"
|
||||||
|
| "lineContingency";
|
||||||
|
length?: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateTableColumns = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
colSpecs: ColumnSpec[],
|
||||||
|
) => {
|
||||||
|
const timeSlots = generateTimeslots(scenario);
|
||||||
|
const columns: ColumnDefinition[] = [];
|
||||||
|
colSpecs.forEach((spec) => {
|
||||||
|
const subColumns: ColumnDefinition[] = [];
|
||||||
|
switch (spec.type) {
|
||||||
|
case "string":
|
||||||
|
case "busRef":
|
||||||
|
columns.push({
|
||||||
|
...columnsCommonAttrs,
|
||||||
|
title: spec.title,
|
||||||
|
field: spec.title,
|
||||||
|
minWidth: spec.width,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
case "lineContingency":
|
||||||
|
columns.push({
|
||||||
|
...columnsCommonAttrs,
|
||||||
|
title: spec.title,
|
||||||
|
field: spec.title,
|
||||||
|
minWidth: spec.width,
|
||||||
|
editor: "list",
|
||||||
|
editorParams: {
|
||||||
|
values: [true, false],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
case "number?":
|
||||||
|
columns.push({
|
||||||
|
...columnsCommonAttrs,
|
||||||
|
title: spec.title,
|
||||||
|
field: spec.title,
|
||||||
|
minWidth: spec.width,
|
||||||
|
formatter: floatFormatter,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "number[T]":
|
||||||
|
timeSlots.forEach((t) => {
|
||||||
|
subColumns.push({
|
||||||
|
...columnsCommonAttrs,
|
||||||
|
title: `${t}`,
|
||||||
|
field: `${spec.title} ${t}`,
|
||||||
|
minWidth: spec.width,
|
||||||
|
formatter: floatFormatter,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
columns.push({
|
||||||
|
title: spec.title,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return columns;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTableData = (
|
||||||
|
container: any,
|
||||||
|
colSpecs: ColumnSpec[],
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): any[] => {
|
||||||
|
const data: any[] = [];
|
||||||
|
const timeslots = generateTimeslots(scenario);
|
||||||
|
let contingencyLines = null;
|
||||||
|
for (const [entryName, entryData] of Object.entries(container) as [
|
||||||
|
string,
|
||||||
|
any,
|
||||||
|
]) {
|
||||||
|
const entry: any = {};
|
||||||
|
for (const spec of colSpecs) {
|
||||||
|
if (spec.title === "Name") {
|
||||||
|
entry["Name"] = entryName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (spec.type) {
|
||||||
|
case "string":
|
||||||
|
case "number":
|
||||||
|
case "number?":
|
||||||
|
case "boolean":
|
||||||
|
case "busRef":
|
||||||
|
entry[spec.title] = entryData[spec.title];
|
||||||
|
break;
|
||||||
|
case "lineContingency":
|
||||||
|
if (contingencyLines === null) {
|
||||||
|
contingencyLines = getContingencyTransmissionLines(scenario);
|
||||||
|
console.log(contingencyLines);
|
||||||
|
}
|
||||||
|
entry[spec.title] = contingencyLines.has(entryName);
|
||||||
|
break;
|
||||||
|
case "number[T]":
|
||||||
|
for (let i = 0; i < timeslots.length; i++) {
|
||||||
|
entry[`${spec.title} ${timeslots[i]}`] = entryData[spec.title][i];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "number[N]":
|
||||||
|
for (let i = 0; i < spec.length!; i++) {
|
||||||
|
let v = entryData[spec.title][i];
|
||||||
|
if (v === undefined || v === null) v = "";
|
||||||
|
entry[`${spec.title} ${i + 1}`] = v;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error(`Unknown type: ${spec.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.push(entry);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateCsv = (data: any[], columns: ColumnDefinition[]) => {
|
||||||
|
const header: string[] = [];
|
||||||
|
const body: string[][] = data.map(() => []);
|
||||||
|
columns.forEach((column) => {
|
||||||
|
if (column.columns) {
|
||||||
|
column.columns.forEach((subcolumn) => {
|
||||||
|
header.push(subcolumn.field!);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
body[i]!.push(data[i]![subcolumn["field"]!]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
header.push(column.field!);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
body[i]!.push(data[i]![column["field"]!]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const csvHeader = header.join(",");
|
||||||
|
const csvBody = body.map((row) => row.join(",")).join("\n");
|
||||||
|
return `${csvHeader}\n${csvBody}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 rowRef = ` (row ${i + 1})`;
|
||||||
|
const name = row["Name"] as string;
|
||||||
|
if (name in data) {
|
||||||
|
return [null, { message: `Name "${name}" is duplicated` + rowRef }];
|
||||||
|
}
|
||||||
|
data[name] = {};
|
||||||
|
|
||||||
|
for (const spec of colSpecs) {
|
||||||
|
if (spec.title === "Name") continue;
|
||||||
|
switch (spec.type) {
|
||||||
|
case "string":
|
||||||
|
data[name][spec.title] = row[spec.title];
|
||||||
|
break;
|
||||||
|
case "number": {
|
||||||
|
const [val, err] = parseNumber(row[spec.title]);
|
||||||
|
if (err) return [null, { message: err.message + rowRef }];
|
||||||
|
data[name][spec.title] = val;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "number?": {
|
||||||
|
const [val, err] = parseNullableNumber(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)) {
|
||||||
|
return [
|
||||||
|
null,
|
||||||
|
{ message: `Bus "${busName}" does not exist` + rowRef },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
data[name][spec.title] = row[spec.title];
|
||||||
|
break;
|
||||||
|
case "number[T]": {
|
||||||
|
data[name][spec.title] = Array(timeslots.length);
|
||||||
|
for (let i = 0; i < timeslots.length; 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":
|
||||||
|
case "lineContingency":
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [data, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const floatFormatter = (cell: CellComponent) => {
|
||||||
|
const v = cell.getValue();
|
||||||
|
if (v === "" || v === null) {
|
||||||
|
return "—";
|
||||||
|
} else {
|
||||||
|
return parseFloat(cell.getValue()).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTimeslots = (scenario: UnitCommitmentScenario) => {
|
||||||
|
const timeHorizonHours = scenario["Parameters"]["Time horizon (h)"];
|
||||||
|
const timeStepMin = scenario["Parameters"]["Time step (min)"];
|
||||||
|
const timeslots: string[] = [];
|
||||||
|
for (
|
||||||
|
let m = 0, offset = 0;
|
||||||
|
m < timeHorizonHours * 60;
|
||||||
|
m += timeStepMin, offset += 1
|
||||||
|
) {
|
||||||
|
const hours = Math.floor(m / 60);
|
||||||
|
const mins = m % 60;
|
||||||
|
const formattedTime = `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
||||||
|
timeslots.push(formattedTime);
|
||||||
|
}
|
||||||
|
return timeslots;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const columnsCommonAttrs: ColumnDefinition = {
|
||||||
|
headerHozAlign: "left",
|
||||||
|
hozAlign: "left",
|
||||||
|
title: "",
|
||||||
|
editor: "input",
|
||||||
|
editorParams: {
|
||||||
|
selectContents: true,
|
||||||
|
},
|
||||||
|
headerWordWrap: true,
|
||||||
|
formatter: "plaintext",
|
||||||
|
headerSort: false,
|
||||||
|
resizable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DataTableProps {
|
||||||
|
onRowDeleted: (rowName: string) => ValidationError | null;
|
||||||
|
onRowRenamed: (
|
||||||
|
oldRowName: string,
|
||||||
|
newRowName: string,
|
||||||
|
) => ValidationError | null;
|
||||||
|
onDataChanged: (
|
||||||
|
rowName: string,
|
||||||
|
key: string,
|
||||||
|
newValue: string,
|
||||||
|
) => ValidationError | null;
|
||||||
|
generateData: () => [any[], ColumnDefinition[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTableHeight(data: any[]): string {
|
||||||
|
const numRows = data.length;
|
||||||
|
const height = 70 + Math.max(Math.min(numRows, 15), 1) * 28;
|
||||||
|
return `${height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTable = (props: DataTableProps) => {
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tableRef = useRef<Tabulator | null>(null);
|
||||||
|
const [isTableBuilt, setTableBuilt] = useState<Boolean>(false);
|
||||||
|
const [activeCell, setActiveCell] = useState<CellComponent | null>(null);
|
||||||
|
const [currentTableData, setCurrentTableData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCellEdited = (cell: CellComponent) => {
|
||||||
|
let newValue = `${cell.getValue()}`;
|
||||||
|
let oldValue = `${cell.getOldValue()}`;
|
||||||
|
if (newValue === oldValue) return;
|
||||||
|
if (cell.getField() === "Name") {
|
||||||
|
if (newValue === "") {
|
||||||
|
const err = props.onRowDeleted(oldValue);
|
||||||
|
if (err) {
|
||||||
|
cell.restoreOldValue();
|
||||||
|
} else {
|
||||||
|
cell
|
||||||
|
.getRow()
|
||||||
|
.delete()
|
||||||
|
.then((r) => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = props.onRowRenamed(oldValue, newValue);
|
||||||
|
if (err) {
|
||||||
|
cell.restoreOldValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const row = cell.getRow().getData();
|
||||||
|
const bus = row["Name"];
|
||||||
|
const err = props.onDataChanged(bus, cell.getField(), newValue);
|
||||||
|
if (err) {
|
||||||
|
cell.restoreOldValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (tableContainerRef.current === null) return;
|
||||||
|
const [data, columns] = props.generateData();
|
||||||
|
const height = computeTableHeight(data);
|
||||||
|
|
||||||
|
if (tableRef.current === null) {
|
||||||
|
tableRef.current = new Tabulator(tableContainerRef.current, {
|
||||||
|
layout: "fitColumns",
|
||||||
|
data: data,
|
||||||
|
columns: columns,
|
||||||
|
height: height,
|
||||||
|
index: "Name",
|
||||||
|
placeholder: "No data",
|
||||||
|
});
|
||||||
|
tableRef.current.on("tableBuilt", () => {
|
||||||
|
setTableBuilt(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTableBuilt) {
|
||||||
|
const newHeight = height;
|
||||||
|
const newColumns = columns;
|
||||||
|
const newTableData = data;
|
||||||
|
const oldRows = tableRef.current.getRows();
|
||||||
|
const activeRowPosition = activeCell?.getRow().getPosition() as number;
|
||||||
|
const activeField = activeCell?.getField();
|
||||||
|
|
||||||
|
// Update data
|
||||||
|
if (newTableData.length === currentTableData.length) {
|
||||||
|
const updatedRows = newTableData.filter((_, i) => {
|
||||||
|
return (
|
||||||
|
JSON.stringify(newTableData[i]) !==
|
||||||
|
JSON.stringify(currentTableData[i])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (updatedRows.length > 0) {
|
||||||
|
tableRef.current
|
||||||
|
.updateData(updatedRows)
|
||||||
|
.then(() => {})
|
||||||
|
.catch((e) => {
|
||||||
|
// WORKAROUND: Updating the same row twice triggers an exception.
|
||||||
|
// In that case, we just update the whole table.
|
||||||
|
console.log(e);
|
||||||
|
tableRef.current!!.replaceData(newTableData).then(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tableRef.current.replaceData(newTableData).then(() => {});
|
||||||
|
}
|
||||||
|
setCurrentTableData(newTableData);
|
||||||
|
|
||||||
|
// Restore active cell selection
|
||||||
|
if (activeCell) {
|
||||||
|
tableRef.current
|
||||||
|
?.getRowFromPosition(activeRowPosition!!)
|
||||||
|
?.getCell(activeField!!)
|
||||||
|
?.edit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update columns
|
||||||
|
let newColCount = 0;
|
||||||
|
newColumns.forEach((col) => {
|
||||||
|
if (col.columns) newColCount += col.columns.length;
|
||||||
|
else newColCount += 1;
|
||||||
|
});
|
||||||
|
if (newColCount !== tableRef.current.getColumns().length) {
|
||||||
|
tableRef.current.setColumns(newColumns);
|
||||||
|
const rows = tableRef.current!.getRows()!;
|
||||||
|
const firstRow = rows[0];
|
||||||
|
if (firstRow) firstRow.scrollTo().then((r) => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update height
|
||||||
|
if (tableRef.current.options.height !== newHeight) {
|
||||||
|
tableRef.current.setHeight(newHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
if (tableRef.current.getRows().length === oldRows.length + 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const rows = tableRef.current!.getRows()!;
|
||||||
|
const lastRow = rows[rows.length - 1]!;
|
||||||
|
lastRow.scrollTo().then((r) => {});
|
||||||
|
lastRow.getCell("Name").edit();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old callbacks
|
||||||
|
tableRef.current.off("cellEdited");
|
||||||
|
tableRef.current.off("cellEditing");
|
||||||
|
tableRef.current.off("cellEditCancelled");
|
||||||
|
|
||||||
|
// Set new callbacks
|
||||||
|
tableRef.current.on("cellEditing", (cell) => {
|
||||||
|
setActiveCell(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
tableRef.current.on("cellEditCancelled", (cell) => {
|
||||||
|
setActiveCell(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tableRef.current.on("cellEdited", (cell) => {
|
||||||
|
setActiveCell(null);
|
||||||
|
onCellEdited(cell);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props, isTableBuilt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tableWrapper">
|
||||||
|
<div ref={tableContainerRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
46
web/frontend/src/components/Common/Forms/Form.module.css
Normal file
46
web/frontend/src/components/Common/Forms/Form.module.css
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.FormWrapper {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--site-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Form {
|
||||||
|
background-color: var(--contrast-0);
|
||||||
|
border: var(--box-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
min-height: 48px;
|
||||||
|
margin: 0 12px;
|
||||||
|
min-width: var(--site-min-width);
|
||||||
|
max-height: 500px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FormRow {
|
||||||
|
display: flex;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FormRow label {
|
||||||
|
width: 350px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FormRow input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: monospace;
|
||||||
|
border: var(--box-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 4px;
|
||||||
|
margin: 2px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FormRow_unit {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
18
web/frontend/src/components/Common/Forms/Form.tsx
Normal file
18
web/frontend/src/components/Common/Forms/Form.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* 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 { ReactNode } from "react";
|
||||||
|
import styles from "./Form.module.css";
|
||||||
|
|
||||||
|
function Form({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.FormWrapper}>
|
||||||
|
<div className={styles.Form}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Form;
|
||||||
96
web/frontend/src/components/Common/Forms/Tables.css
Normal file
96
web/frontend/src/components/Common/Forms/Tables.css
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--site-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator {
|
||||||
|
background-color: var(--contrast-0);
|
||||||
|
border: var(--box-border) !important;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--box-shadow);
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: var(--site-min-width);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator .tabulator-header {
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--contrast-100);
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator .tabulator-header .subtitle {
|
||||||
|
color: var(--contrast-80);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator .tabulator-header .tabulator-col {
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator .tabulator-header .tabulator-col .tabulator-col-content {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 8px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator .tabulator-header .tabulator-col:last-child {
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-row .tabulator-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 28px;
|
||||||
|
height: 28px;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-row-even {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-row-odd {
|
||||||
|
background-color: rgba(0, 0, 0, 0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-row .tabulator-cell.tabulator-editing {
|
||||||
|
border: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
background-color: #cee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-row .tabulator-cell.tabulator-editing input {
|
||||||
|
font-family: monospace;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-col-group-cols {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabulator-placeholder {
|
||||||
|
width: 100px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tabulator-placeholder * {
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: var(--contrast-60) !important;
|
||||||
|
}
|
||||||
58
web/frontend/src/components/Common/Forms/TextInputRow.tsx
Normal file
58
web/frontend/src/components/Common/Forms/TextInputRow.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* 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 formStyles from "./Form.module.css";
|
||||||
|
import HelpButton from "../Buttons/HelpButton";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { ValidationError } from "../../../core/Data/validate";
|
||||||
|
|
||||||
|
interface TextInputRowProps {
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
tooltip: string;
|
||||||
|
initialValue: string;
|
||||||
|
onChange: (newValue: string) => ValidationError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInputRow(props: TextInputRowProps) {
|
||||||
|
const [savedValue, setSavedValue] = useState(props.initialValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = props.initialValue;
|
||||||
|
}
|
||||||
|
setSavedValue(props.initialValue);
|
||||||
|
}, [props.initialValue]);
|
||||||
|
|
||||||
|
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = event.target.value;
|
||||||
|
if (newValue === savedValue) return;
|
||||||
|
const err = props.onChange(newValue);
|
||||||
|
if (err) {
|
||||||
|
inputRef.current!.value = savedValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={formStyles.FormRow}>
|
||||||
|
<label>
|
||||||
|
{props.label}
|
||||||
|
<span className={formStyles.FormRow_unit}> ({props.unit})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={savedValue}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
<HelpButton text={props.tooltip} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextInputRow;
|
||||||
23
web/frontend/src/components/Common/Forms/Toast.module.css
Normal file
23
web/frontend/src/components/Common/Forms/Toast.module.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.Toast {
|
||||||
|
width: 600px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 4px 4px 16px -2px rgba(0, 0, 0, 0.5);
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #424242;
|
||||||
|
color: white;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 48px;
|
||||||
|
}
|
||||||
35
web/frontend/src/components/Common/Forms/Toast.tsx
Normal file
35
web/frontend/src/components/Common/Forms/Toast.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./Toast.module.css";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toast = (props: ToastProps) => {
|
||||||
|
const [isVisible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.message.length === 0) return;
|
||||||
|
setVisible(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [props.message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.Toast} style={{ opacity: isVisible ? 1 : 0 }}>
|
||||||
|
{props.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.SectionHeader {
|
||||||
|
max-width: var(--site-max-width);
|
||||||
|
min-width: var(--site-min-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--contrast-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SectionHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SectionButtonsContainer {
|
||||||
|
float: right;
|
||||||
|
height: 64px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 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 styles from "./SectionHeader.module.css";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ title, children }: SectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.SectionHeader}>
|
||||||
|
<div className={styles.SectionButtonsContainer}>{children}</div>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionHeader;
|
||||||
17
web/frontend/src/components/Common/io.ts
Normal file
17
web/frontend/src/components/Common/io.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function offerDownload(data: string, type: string, filename: string) {
|
||||||
|
const dataBlob = new Blob([data], { type: type });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
132
web/frontend/src/core/Data/fixtures.test.ts
Normal file
132
web/frontend/src/core/Data/fixtures.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* 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 { UnitCommitmentScenario } from "./types";
|
||||||
|
|
||||||
|
export const TEST_DATA_1: UnitCommitmentScenario = {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 5,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
},
|
||||||
|
Buses: {
|
||||||
|
b1: { "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044] },
|
||||||
|
b2: { "Load (MW)": [14.03739, 13.48563, 13.11797, 12.9009, 13.03939] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] },
|
||||||
|
},
|
||||||
|
Generators: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Transmission lines": {
|
||||||
|
l1: {
|
||||||
|
"Source bus": "b1",
|
||||||
|
"Target bus": "b2",
|
||||||
|
"Susceptance (S)": 29.49686,
|
||||||
|
"Normal flow limit (MW)": 15000.0,
|
||||||
|
"Emergency flow limit (MW)": 20000.0,
|
||||||
|
"Flow limit penalty ($/MW)": 5000.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Storage units": {
|
||||||
|
su1: {
|
||||||
|
Bus: "b1",
|
||||||
|
"Minimum level (MWh)": 10.0,
|
||||||
|
"Maximum level (MWh)": 100.0,
|
||||||
|
"Charge cost ($/MW)": 2.0,
|
||||||
|
"Discharge cost ($/MW)": 1.0,
|
||||||
|
"Charge efficiency": 0.8,
|
||||||
|
"Discharge efficiency": 0.85,
|
||||||
|
"Loss factor": 0.01,
|
||||||
|
"Minimum charge rate (MW)": 5.0,
|
||||||
|
"Maximum charge rate (MW)": 10.0,
|
||||||
|
"Minimum discharge rate (MW)": 4.0,
|
||||||
|
"Maximum discharge rate (MW)": 8.0,
|
||||||
|
"Initial level (MWh)": 20.0,
|
||||||
|
"Last period minimum level (MWh)": 21.0,
|
||||||
|
"Last period maximum level (MWh)": 22.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Price-sensitive loads": {
|
||||||
|
ps1: {
|
||||||
|
Bus: "b3",
|
||||||
|
"Revenue ($/MW)": 23.0,
|
||||||
|
"Demand (MW)": [50, 50, 50, 50, 50],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Contingencies: {
|
||||||
|
l1: {
|
||||||
|
"Affected generators": [],
|
||||||
|
"Affected lines": ["l1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_DATA_2: UnitCommitmentScenario = {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 2,
|
||||||
|
"Time step (min)": 30,
|
||||||
|
},
|
||||||
|
Buses: {
|
||||||
|
b1: { "Load (MW)": [30, 30, 30, 30] },
|
||||||
|
b2: { "Load (MW)": [10, 20, 30, 40] },
|
||||||
|
b3: { "Load (MW)": [0, 30, 0, 40] },
|
||||||
|
},
|
||||||
|
Contingencies: {},
|
||||||
|
Generators: {},
|
||||||
|
"Transmission lines": {},
|
||||||
|
"Storage units": {},
|
||||||
|
"Price-sensitive loads": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_DATA_BLANK: UnitCommitmentScenario = {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 5,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
},
|
||||||
|
Buses: {},
|
||||||
|
Contingencies: {},
|
||||||
|
Generators: {},
|
||||||
|
"Transmission lines": {},
|
||||||
|
"Storage units": {},
|
||||||
|
"Price-sensitive loads": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("fixtures", () => {});
|
||||||
26
web/frontend/src/core/Data/fixtures.tsx
Normal file
26
web/frontend/src/core/Data/fixtures.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* 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 { UnitCommitmentScenario } from "./types";
|
||||||
|
|
||||||
|
export interface Buses {
|
||||||
|
[busName: string]: { "Load (MW)": number[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLANK_SCENARIO: UnitCommitmentScenario = {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 24,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
},
|
||||||
|
Buses: {},
|
||||||
|
Generators: {},
|
||||||
|
"Transmission lines": {},
|
||||||
|
"Storage units": {},
|
||||||
|
"Price-sensitive loads": {},
|
||||||
|
Contingencies: {},
|
||||||
|
};
|
||||||
34
web/frontend/src/core/Data/migrate.test.ts
Normal file
34
web/frontend/src/core/Data/migrate.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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 fs from "node:fs";
|
||||||
|
import pako from "pako";
|
||||||
|
import { migrateToV03, migrateToV04 } from "./migrate";
|
||||||
|
|
||||||
|
function readJsonGz(filename: string) {
|
||||||
|
const compressedData = fs.readFileSync(filename);
|
||||||
|
const decompressedData = pako.inflate(compressedData, { to: "string" });
|
||||||
|
return JSON.parse(decompressedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("migrateToV03", () => {
|
||||||
|
const jsonData = readJsonGz("../test/fixtures/ucjl-0.2.json.gz");
|
||||||
|
migrateToV03(jsonData);
|
||||||
|
assert.deepEqual(jsonData.Reserves, {
|
||||||
|
r1: {
|
||||||
|
"Amount (MW)": 100,
|
||||||
|
"Shortfall penalty ($/MW)": 1000,
|
||||||
|
Type: "spinning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrateToV04", () => {
|
||||||
|
const jsonData = readJsonGz("../test/fixtures/ucjl-0.3.json.gz");
|
||||||
|
migrateToV04(jsonData);
|
||||||
|
assert.equal(jsonData.Generators["g1"].Type, "Thermal");
|
||||||
|
});
|
||||||
56
web/frontend/src/core/Data/migrate.ts
Normal file
56
web/frontend/src/core/Data/migrate.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* 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 "./validate";
|
||||||
|
|
||||||
|
export const migrate = (json: any): ValidationError | null => {
|
||||||
|
const version = json.Parameters?.Version;
|
||||||
|
if (!version) {
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"The provided input file cannot be loaded because it does not " +
|
||||||
|
"specify what version of UnitCommitment.jl it was written for.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!["0.2", "0.3", "0.4"].includes(version)) {
|
||||||
|
return { message: `Unsupported file version: ${version}` };
|
||||||
|
}
|
||||||
|
if (version < "0.3") migrateToV03(json);
|
||||||
|
if (version < "0.4") migrateToV04(json);
|
||||||
|
json.Parameters.Version = "0.4";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const migrateToV03 = (json: any): void => {
|
||||||
|
if (json.Reserves && json.Reserves["Spinning (MW)"] != null) {
|
||||||
|
const amount = json.Reserves["Spinning (MW)"];
|
||||||
|
json.Reserves = {
|
||||||
|
r1: {
|
||||||
|
Type: "spinning",
|
||||||
|
"Amount (MW)": amount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (json.Generators) {
|
||||||
|
for (const genName in json.Generators) {
|
||||||
|
const gen = json.Generators[genName];
|
||||||
|
if (gen["Provides spinning reserves?"] === true) {
|
||||||
|
gen["Reserve eligibility"] = ["r1"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const migrateToV04 = (json: any): void => {
|
||||||
|
if (json.Generators) {
|
||||||
|
for (const genName in json.Generators) {
|
||||||
|
const gen = json.Generators[genName];
|
||||||
|
if (gen.Type == null) {
|
||||||
|
gen.Type = "Thermal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
373
web/frontend/src/core/Data/schema.ts
Normal file
373
web/frontend/src/core/Data/schema.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const schema = {
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
title: "Schema for Unit Commitment Input File",
|
||||||
|
definitions: {
|
||||||
|
Parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
Version: {
|
||||||
|
type: "string",
|
||||||
|
const: "0.4",
|
||||||
|
description: "Version of UnitCommitment.jl",
|
||||||
|
},
|
||||||
|
"Time horizon (min)": {
|
||||||
|
type: "number",
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
description: "Length of the planning horizon in minutes",
|
||||||
|
},
|
||||||
|
"Time horizon (h)": {
|
||||||
|
type: "number",
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
description: "Length of the planning horizon in hours",
|
||||||
|
},
|
||||||
|
"Time step (min)": {
|
||||||
|
type: "number",
|
||||||
|
default: 60,
|
||||||
|
enum: [60, 30, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1],
|
||||||
|
description: "Must be a divisor of 60",
|
||||||
|
},
|
||||||
|
"Power balance penalty ($/MW)": {
|
||||||
|
type: "number",
|
||||||
|
default: 1000.0,
|
||||||
|
minimum: 0,
|
||||||
|
description: "Penalty for system-wide shortage or surplus",
|
||||||
|
},
|
||||||
|
"Scenario name": {
|
||||||
|
type: "string",
|
||||||
|
default: "s1",
|
||||||
|
description: "Name of the scenario",
|
||||||
|
},
|
||||||
|
"Scenario weight": {
|
||||||
|
type: "number",
|
||||||
|
default: 1.0,
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
description: "Weight of the scenario",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Time step (min)", "Power balance penalty ($/MW)"],
|
||||||
|
oneOf: [
|
||||||
|
{ required: ["Time horizon (min)"] },
|
||||||
|
{ required: ["Time horizon (h)"] },
|
||||||
|
],
|
||||||
|
not: {
|
||||||
|
required: ["Time horizon (min)", "Time horizon (h)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Bus: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
"Load (MW)": {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "null" },
|
||||||
|
{ type: "number" },
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
oneOf: [{ type: "number" }, { type: "null" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TransmissionLines: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
"Source bus": {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
"Target bus": {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
not: {
|
||||||
|
const: { $data: "1/Source bus" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Susceptance (S)": {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
"Normal flow limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Emergency flow limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Flow limit penalty ($/MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
default: 5000.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Source bus", "Target bus", "Susceptance (S)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StorageUnits: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
Bus: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
"Minimum level (MWh)": {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
"Maximum level (MWh)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Allow simultaneous charging and discharging": {
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
"Charge cost ($/MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Discharge cost ($/MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Charge efficiency": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 1,
|
||||||
|
},
|
||||||
|
"Discharge efficiency": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 1,
|
||||||
|
},
|
||||||
|
"Loss factor": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Minimum charge rate (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Maximum charge rate (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Minimum discharge rate (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Maximum discharge rate (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Initial level (MWh)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Last period minimum level (MWh)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Last period maximum level (MWh)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Bus"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Generators: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
if: {
|
||||||
|
properties: {
|
||||||
|
Type: { const: "Thermal" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
then: {
|
||||||
|
properties: {
|
||||||
|
Bus: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
Type: {
|
||||||
|
type: "string",
|
||||||
|
const: "Thermal",
|
||||||
|
},
|
||||||
|
"Production cost curve (MW)": {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
"Production cost curve ($)": {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
"Startup costs ($)": {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
default: [0.0],
|
||||||
|
},
|
||||||
|
"Startup delays (h)": {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
},
|
||||||
|
default: [1],
|
||||||
|
},
|
||||||
|
"Minimum uptime (h)": {
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Minimum downtime (h)": {
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Ramp up limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Ramp down limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Startup limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Shutdown limit (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
"Initial status (h)": {
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
not: { const: 0 },
|
||||||
|
},
|
||||||
|
"Initial power (MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
"Must run?": {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
"Bus",
|
||||||
|
"Type",
|
||||||
|
"Production cost curve (MW)",
|
||||||
|
"Production cost curve ($)",
|
||||||
|
"Initial status (h)",
|
||||||
|
"Initial power (MW)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
else: {
|
||||||
|
properties: {
|
||||||
|
Type: { const: "Profiled" },
|
||||||
|
Bus: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
"Maximum power (MW)": {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Cost ($/MW)": {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Type", "Bus", "Maximum power (MW)", "Cost ($/MW)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Contingencies: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
"Affected lines": {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
maxItems: 1,
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Affected lines"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
Parameters: {
|
||||||
|
$ref: "#/definitions/Parameters",
|
||||||
|
},
|
||||||
|
Buses: {
|
||||||
|
$ref: "#/definitions/Bus",
|
||||||
|
},
|
||||||
|
"Transmission lines": {
|
||||||
|
$ref: "#/definitions/TransmissionLines",
|
||||||
|
},
|
||||||
|
"Storage units": {
|
||||||
|
$ref: "#/definitions/StorageUnits",
|
||||||
|
},
|
||||||
|
Generators: {
|
||||||
|
$ref: "#/definitions/Generators",
|
||||||
|
},
|
||||||
|
Contingencies: {
|
||||||
|
$ref: "#/definitions/Contingencies",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["Parameters"],
|
||||||
|
};
|
||||||
119
web/frontend/src/core/Data/types.tsx
Normal file
119
web/frontend/src/core/Data/types.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from "./fixtures";
|
||||||
|
|
||||||
|
export interface Generators {
|
||||||
|
[name: string]: ProfiledUnit | ThermalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfiledUnit {
|
||||||
|
Bus: string;
|
||||||
|
Type: "Profiled";
|
||||||
|
"Minimum power (MW)": number[];
|
||||||
|
"Maximum power (MW)": number[];
|
||||||
|
"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 | null;
|
||||||
|
"Ramp down limit (MW)": number | null;
|
||||||
|
"Startup limit (MW)": number | null;
|
||||||
|
"Shutdown limit (MW)": number | null;
|
||||||
|
"Minimum downtime (h)": number;
|
||||||
|
"Minimum uptime (h)": number;
|
||||||
|
"Initial status (h)": number;
|
||||||
|
"Initial power (MW)": number;
|
||||||
|
"Must run?": boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransmissionLine {
|
||||||
|
"Source bus": string;
|
||||||
|
"Target bus": string;
|
||||||
|
"Susceptance (S)": number;
|
||||||
|
"Normal flow limit (MW)": number | null;
|
||||||
|
"Emergency flow limit (MW)": number | null;
|
||||||
|
"Flow limit penalty ($/MW)": number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageUnit {
|
||||||
|
Bus: string;
|
||||||
|
"Minimum level (MWh)": number;
|
||||||
|
"Maximum level (MWh)": number;
|
||||||
|
"Charge cost ($/MW)": number;
|
||||||
|
"Discharge cost ($/MW)": number;
|
||||||
|
"Charge efficiency": number;
|
||||||
|
"Discharge efficiency": number;
|
||||||
|
"Loss factor": number;
|
||||||
|
"Minimum charge rate (MW)": number;
|
||||||
|
"Maximum charge rate (MW)": number;
|
||||||
|
"Minimum discharge rate (MW)": number;
|
||||||
|
"Maximum discharge rate (MW)": number;
|
||||||
|
"Initial level (MWh)": number;
|
||||||
|
"Last period minimum level (MWh)": number;
|
||||||
|
"Last period maximum level (MWh)": number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSensitiveLoad {
|
||||||
|
Bus: string;
|
||||||
|
"Revenue ($/MW)": number;
|
||||||
|
"Demand (MW)": number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contingency {
|
||||||
|
"Affected lines": string[];
|
||||||
|
"Affected generators": string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitCommitmentScenario {
|
||||||
|
Parameters: {
|
||||||
|
Version: string;
|
||||||
|
"Power balance penalty ($/MW)": number;
|
||||||
|
"Time horizon (h)": number;
|
||||||
|
"Time step (min)": number;
|
||||||
|
};
|
||||||
|
Buses: Buses;
|
||||||
|
Generators: Generators;
|
||||||
|
"Transmission lines": {
|
||||||
|
[name: string]: TransmissionLine;
|
||||||
|
};
|
||||||
|
"Storage units": {
|
||||||
|
[name: string]: StorageUnit;
|
||||||
|
};
|
||||||
|
"Price-sensitive loads": {
|
||||||
|
[name: string]: PriceSensitiveLoad;
|
||||||
|
};
|
||||||
|
Contingencies: {
|
||||||
|
[name: string]: Contingency;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypedGenerators = <T extends any>(
|
||||||
|
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<ProfiledUnit>(scenario, "Profiled");
|
||||||
|
export const getThermalGenerators = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): { [key: string]: ThermalUnit } =>
|
||||||
|
getTypedGenerators<ThermalUnit>(scenario, "Thermal");
|
||||||
22
web/frontend/src/core/Data/validate.ts
Normal file
22
web/frontend/src/core/Data/validate.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* 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 { schema } from "./schema";
|
||||||
|
import Ajv from "ajv";
|
||||||
|
|
||||||
|
// Create Ajv instance with detailed debug options
|
||||||
|
const ajv = new Ajv({
|
||||||
|
useDefaults: true,
|
||||||
|
verbose: true,
|
||||||
|
allErrors: true,
|
||||||
|
$data: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validate = ajv.compile(schema);
|
||||||
71
web/frontend/src/core/Operations/busOps.test.ts
Normal file
71
web/frontend/src/core/Operations/busOps.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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 { changeBusData, createBus, deleteBus, renameBus } from "./busOps";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { TEST_DATA_1 } from "../Data/fixtures.test";
|
||||||
|
|
||||||
|
test("createBus", () => {
|
||||||
|
const newScenario = createBus(TEST_DATA_1);
|
||||||
|
assert.deepEqual(Object.keys(newScenario.Buses), ["b1", "b2", "b3", "b4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeBusData", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
let err = null;
|
||||||
|
|
||||||
|
[scenario, err] = changeBusData("b1", "Load (MW) 00:00", "99", scenario);
|
||||||
|
assert.equal(err, null);
|
||||||
|
[scenario, err] = changeBusData("b1", "Load (MW) 03:00", "99", scenario);
|
||||||
|
assert.equal(err, null);
|
||||||
|
|
||||||
|
[scenario, err] = changeBusData("b3", "Load (MW) 04:00", "99", scenario);
|
||||||
|
assert.equal(err, null);
|
||||||
|
|
||||||
|
assert.deepEqual(scenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [99, 34.38835, 33.45083, 99, 33.25044] },
|
||||||
|
b2: { "Load (MW)": [14.03739, 13.48563, 13.11797, 12.9009, 13.03939] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 99] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeBusData with invalid numbers", () => {
|
||||||
|
let [, err] = changeBusData("b1", "Load (MW) 00:00", "xx", TEST_DATA_1);
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, '"xx" is not a valid number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleteBus", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
scenario = deleteBus("b2", scenario);
|
||||||
|
assert.deepEqual(scenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renameBus", () => {
|
||||||
|
let [scenario, err] = renameBus("b1", "b99", TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(scenario.Buses, {
|
||||||
|
b99: { "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044] },
|
||||||
|
b2: { "Load (MW)": [14.03739, 13.48563, 13.11797, 12.9009, 13.03939] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(scenario.Generators["pu1"], {
|
||||||
|
Bus: "b99",
|
||||||
|
Type: "Profiled",
|
||||||
|
"Cost ($/MW)": 12.5,
|
||||||
|
"Maximum power (MW)": [10, 12, 13, 15, 20],
|
||||||
|
"Minimum power (MW)": [0, 0, 0, 0, 0],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renameBus with duplicated name", () => {
|
||||||
|
let [, err] = renameBus("b3", "b1", TEST_DATA_1);
|
||||||
|
assert(err != null);
|
||||||
|
assert.equal(err.message, `b1 already exists`);
|
||||||
|
});
|
||||||
87
web/frontend/src/core/Operations/busOps.ts
Normal file
87
web/frontend/src/core/Operations/busOps.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from "../Data/fixtures";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
import { generateTimeslots } from "../../components/Common/Forms/DataTable";
|
||||||
|
import {
|
||||||
|
changeData,
|
||||||
|
generateUniqueName,
|
||||||
|
renameItemInObject,
|
||||||
|
} from "./commonOps";
|
||||||
|
import { BusesColumnSpec } from "../../components/CaseBuilder/Buses";
|
||||||
|
import { UnitCommitmentScenario } from "../Data/types";
|
||||||
|
|
||||||
|
export const createBus = (scenario: UnitCommitmentScenario) => {
|
||||||
|
const name = generateUniqueName(scenario.Buses, "b");
|
||||||
|
const timeslots = generateTimeslots(scenario);
|
||||||
|
return {
|
||||||
|
...scenario,
|
||||||
|
Buses: {
|
||||||
|
...scenario.Buses,
|
||||||
|
[name]: {
|
||||||
|
"Load (MW)": Array(timeslots.length).fill(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeBusData = (
|
||||||
|
bus: string,
|
||||||
|
field: string,
|
||||||
|
newValueStr: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
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) => {
|
||||||
|
const { [bus]: _, ...newBuses } = scenario.Buses;
|
||||||
|
const newGenerators = { ...scenario.Generators };
|
||||||
|
|
||||||
|
// Update generators
|
||||||
|
for (const genName in scenario.Generators) {
|
||||||
|
let gen = scenario.Generators[genName]!;
|
||||||
|
if (gen["Bus"] === bus) delete newGenerators[genName];
|
||||||
|
}
|
||||||
|
return { ...scenario, Buses: newBuses, Generators: newGenerators };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameBus = (
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newBuses, err] = renameItemInObject(oldName, newName, scenario.Buses);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
|
||||||
|
// Update generators
|
||||||
|
const newGenerators = { ...scenario.Generators };
|
||||||
|
for (const genName in scenario.Generators) {
|
||||||
|
let gen = newGenerators[genName]!;
|
||||||
|
if (gen["Bus"] === oldName) {
|
||||||
|
newGenerators[genName] = { ...gen, Bus: newName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ ...scenario, Buses: newBuses, Generators: newGenerators }, null];
|
||||||
|
};
|
||||||
30
web/frontend/src/core/Operations/commonOps.test.ts
Normal file
30
web/frontend/src/core/Operations/commonOps.test.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
258
web/frontend/src/core/Operations/commonOps.ts
Normal file
258
web/frontend/src/core/Operations/commonOps.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/*
|
||||||
|
* 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 { ColumnSpec } from "../../components/Common/Forms/DataTable";
|
||||||
|
import { UnitCommitmentScenario } from "../Data/types";
|
||||||
|
|
||||||
|
export const renameItemInObject = <T>(
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
container: { [key: string]: T },
|
||||||
|
): [{ [key: string]: T }, ValidationError | null] => {
|
||||||
|
if (newName in container) {
|
||||||
|
return [container, { message: `${newName} already exists` }];
|
||||||
|
}
|
||||||
|
const newContainer = Object.keys(container).reduce(
|
||||||
|
(acc, val) => {
|
||||||
|
if (val === oldName) {
|
||||||
|
acc[newName] = container[val]!;
|
||||||
|
} else {
|
||||||
|
acc[val] = container[val]!;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as { [key: string]: T },
|
||||||
|
);
|
||||||
|
return [newContainer, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateUniqueName = (container: any, prefix: string): string => {
|
||||||
|
let counter = 1;
|
||||||
|
let name = `${prefix}${counter}`;
|
||||||
|
while (name in container) {
|
||||||
|
counter++;
|
||||||
|
name = `${prefix}${counter}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseNumber = (
|
||||||
|
valueStr: string,
|
||||||
|
): [number, ValidationError | null] => {
|
||||||
|
if (valueStr === "") {
|
||||||
|
return [0, { message: "Field must not be blank" }];
|
||||||
|
}
|
||||||
|
const valueFloat = parseFloat(valueStr);
|
||||||
|
if (isNaN(valueFloat)) {
|
||||||
|
return [0, { message: `"${valueStr}" is not a valid number` }];
|
||||||
|
} else {
|
||||||
|
return [valueFloat, null];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseNullableNumber = (
|
||||||
|
valueStr: string,
|
||||||
|
): [number | null, ValidationError | null] => {
|
||||||
|
if (valueStr === "") return [null, null];
|
||||||
|
return parseNumber(valueStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 },
|
||||||
|
nullable: boolean = false,
|
||||||
|
): [{ [key: string]: any }, ValidationError | null] => {
|
||||||
|
// Parse value
|
||||||
|
const [newValueFloat, err] = nullable
|
||||||
|
? parseNullableNumber(newValueStr)
|
||||||
|
: parseNumber(newValueStr);
|
||||||
|
if (err) return [container, err];
|
||||||
|
|
||||||
|
// Build the new object
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...container,
|
||||||
|
[field]: newValueFloat,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeBooleanData = (
|
||||||
|
field: string,
|
||||||
|
newValueStr: string,
|
||||||
|
container: { [key: string]: any },
|
||||||
|
): [{ [key: string]: any }, ValidationError | null] => {
|
||||||
|
// Parse value
|
||||||
|
const [newValueBool, err] = parseBool(newValueStr);
|
||||||
|
if (err) return [container, err];
|
||||||
|
|
||||||
|
// Build the new object
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...container,
|
||||||
|
[field]: newValueBool,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeNumberVecTData = (
|
||||||
|
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 changeNumberVecNData = (
|
||||||
|
field: string,
|
||||||
|
offset: string,
|
||||||
|
newValueStr: string,
|
||||||
|
container: { [key: string]: any },
|
||||||
|
): [{ [key: string]: any }, ValidationError | null] => {
|
||||||
|
const oldVec = container[field];
|
||||||
|
const newVec = [...container[field]];
|
||||||
|
const idx = parseInt(offset) - 1;
|
||||||
|
|
||||||
|
if (newValueStr === "") {
|
||||||
|
// Trim the vector
|
||||||
|
newVec.splice(idx, oldVec.length - idx);
|
||||||
|
} else {
|
||||||
|
// Parse new value
|
||||||
|
const [newValueFloat, err] = parseNumber(newValueStr);
|
||||||
|
if (err) return [container, err];
|
||||||
|
|
||||||
|
// Increase the length of the vector
|
||||||
|
if (idx >= oldVec.length) {
|
||||||
|
for (let i = oldVec.length; i < idx; i++) {
|
||||||
|
newVec[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign new value
|
||||||
|
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]+)([0-9:]+)?$/);
|
||||||
|
const fieldName = match![1]!.trim();
|
||||||
|
const fieldOffset = 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 changeNumberData(fieldName, newValueStr, container, true);
|
||||||
|
case "number[T]":
|
||||||
|
return changeNumberVecTData(
|
||||||
|
fieldName,
|
||||||
|
fieldOffset!,
|
||||||
|
newValueStr,
|
||||||
|
container,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
case "number[N]":
|
||||||
|
return changeNumberVecNData(
|
||||||
|
fieldName,
|
||||||
|
fieldOffset!,
|
||||||
|
newValueStr,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
case "boolean":
|
||||||
|
return changeBooleanData(fieldName, newValueStr, container);
|
||||||
|
default:
|
||||||
|
throw Error(`Unknown type: ${spec.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Error(`Unknown field: ${fieldName}`);
|
||||||
|
};
|
||||||
|
export const assertBusesNotEmpty = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): ValidationError | null => {
|
||||||
|
if (Object.keys(scenario.Buses).length === 0)
|
||||||
|
return { message: "This component requires an existing bus." };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
151
web/frontend/src/core/Operations/generatorOps.test.ts
Normal file
151
web/frontend/src/core/Operations/generatorOps.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* 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, TEST_DATA_BLANK } from "../Data/fixtures.test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import {
|
||||||
|
changeProfiledUnitData,
|
||||||
|
changeThermalUnitData,
|
||||||
|
createProfiledUnit,
|
||||||
|
createThermalUnit,
|
||||||
|
deleteGenerator,
|
||||||
|
renameGenerator,
|
||||||
|
} from "./generatorOps";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
|
||||||
|
test("createProfiledUnit", () => {
|
||||||
|
const [newScenario, err] = createProfiledUnit(TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.equal(Object.keys(newScenario.Generators).length, 4);
|
||||||
|
assert("pu3" in newScenario.Generators);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createThermalUnit", () => {
|
||||||
|
const [newScenario, err] = createThermalUnit(TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.equal(Object.keys(newScenario.Generators).length, 4);
|
||||||
|
assert("g2" in newScenario.Generators);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createProfiledUnit with blank file", () => {
|
||||||
|
const [, err] = createProfiledUnit(TEST_DATA_BLANK);
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "This component requires an existing bus.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeProfiledUnitData", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
let err: ValidationError | 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["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("changeThermalUnitData", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
let err: ValidationError | null;
|
||||||
|
[scenario, err] = changeThermalUnitData(
|
||||||
|
"g1",
|
||||||
|
"Ramp up limit (MW)",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert(!err);
|
||||||
|
[scenario, err] = changeThermalUnitData(
|
||||||
|
"g1",
|
||||||
|
"Startup costs ($) 2",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert(!err);
|
||||||
|
[scenario, err] = changeThermalUnitData(
|
||||||
|
"g1",
|
||||||
|
"Production cost curve ($) 7",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert(!err);
|
||||||
|
[scenario, err] = changeThermalUnitData(
|
||||||
|
"g1",
|
||||||
|
"Production cost curve (MW) 3",
|
||||||
|
"",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert(!err);
|
||||||
|
[scenario, err] = changeThermalUnitData("g1", "Must run?", "true", scenario);
|
||||||
|
assert(!err);
|
||||||
|
assert.deepEqual(scenario.Generators["g1"], {
|
||||||
|
Bus: "b1",
|
||||||
|
Type: "Thermal",
|
||||||
|
"Production cost curve (MW)": [100.0, 110],
|
||||||
|
"Production cost curve ($)": [1400.0, 1600.0, 2200.0, 2400.0, 0, 0, 99],
|
||||||
|
"Startup costs ($)": [300.0, 99.0],
|
||||||
|
"Startup delays (h)": [1, 4],
|
||||||
|
"Ramp up limit (MW)": 99,
|
||||||
|
"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?": true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.equal(Object.keys(newScenario.Generators).length, 2);
|
||||||
|
assert("g1" 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],
|
||||||
|
});
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
});
|
||||||
152
web/frontend/src/core/Operations/generatorOps.ts
Normal file
152
web/frontend/src/core/Operations/generatorOps.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* 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 { generateTimeslots } from "../../components/Common/Forms/DataTable";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
import {
|
||||||
|
assertBusesNotEmpty,
|
||||||
|
changeData,
|
||||||
|
generateUniqueName,
|
||||||
|
renameItemInObject,
|
||||||
|
} from "./commonOps";
|
||||||
|
import { ProfiledUnitsColumnSpec } from "../../components/CaseBuilder/ProfiledUnits";
|
||||||
|
import { ThermalUnitsColumnSpec } from "../../components/CaseBuilder/ThermalUnits";
|
||||||
|
import { Generators, UnitCommitmentScenario } from "../Data/types";
|
||||||
|
|
||||||
|
export const createProfiledUnit = (
|
||||||
|
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.Generators, "pu");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
Generators: {
|
||||||
|
...scenario.Generators,
|
||||||
|
[name]: {
|
||||||
|
Bus: busName,
|
||||||
|
Type: "Profiled",
|
||||||
|
"Cost ($/MW)": 0,
|
||||||
|
"Minimum power (MW)": Array(timeslots.length).fill(0),
|
||||||
|
"Maximum power (MW)": Array(timeslots.length).fill(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createThermalUnit = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const err = assertBusesNotEmpty(scenario);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
const busName = Object.keys(scenario.Buses)[0]!;
|
||||||
|
const name = generateUniqueName(scenario.Generators, "g");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
Generators: {
|
||||||
|
...scenario.Generators,
|
||||||
|
[name]: {
|
||||||
|
Bus: busName,
|
||||||
|
Type: "Thermal",
|
||||||
|
"Production cost curve (MW)": [0, 100],
|
||||||
|
"Production cost curve ($)": [0, 10],
|
||||||
|
"Startup costs ($)": [0],
|
||||||
|
"Startup delays (h)": [1],
|
||||||
|
"Ramp up limit (MW)": null,
|
||||||
|
"Ramp down limit (MW)": null,
|
||||||
|
"Startup limit (MW)": null,
|
||||||
|
"Shutdown limit (MW)": null,
|
||||||
|
"Minimum downtime (h)": 1,
|
||||||
|
"Minimum uptime (h)": 1,
|
||||||
|
"Initial status (h)": -24,
|
||||||
|
"Initial power (MW)": 0,
|
||||||
|
"Must run?": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 changeThermalUnitData = (
|
||||||
|
generator: string,
|
||||||
|
field: string,
|
||||||
|
newValueStr: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newGen, err] = changeData(
|
||||||
|
field,
|
||||||
|
newValueStr,
|
||||||
|
scenario.Generators[generator]!,
|
||||||
|
ThermalUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
Generators: {
|
||||||
|
...scenario.Generators,
|
||||||
|
[generator]: newGen,
|
||||||
|
} as Generators,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteGenerator = (
|
||||||
|
name: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): UnitCommitmentScenario => {
|
||||||
|
const { [name]: _, ...newGenerators } = scenario.Generators;
|
||||||
|
return { ...scenario, Generators: newGenerators };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameGenerator = (
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newGen, err] = renameItemInObject(
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
scenario.Generators,
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
return [{ ...scenario, Generators: newGen }, null];
|
||||||
|
};
|
||||||
137
web/frontend/src/core/Operations/parameterOps.test.ts
Normal file
137
web/frontend/src/core/Operations/parameterOps.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
changeTimeHorizon,
|
||||||
|
changeTimeStep,
|
||||||
|
evaluatePwlFunction,
|
||||||
|
} from "./parameterOps";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { TEST_DATA_1, TEST_DATA_2 } from "../Data/fixtures.test";
|
||||||
|
|
||||||
|
test("changeTimeHorizon: Shrink 1", () => {
|
||||||
|
const [newScenario, err] = changeTimeHorizon(TEST_DATA_1, "3");
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(newScenario.Parameters, {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 3,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
});
|
||||||
|
assert.deepEqual(newScenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [35.79534, 34.38835, 33.45083] },
|
||||||
|
b2: { "Load (MW)": [14.03739, 13.48563, 13.11797] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTimeHorizon: Shrink 2", () => {
|
||||||
|
const [newScenario, err] = changeTimeHorizon(TEST_DATA_2, "1");
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(newScenario.Parameters, {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 1,
|
||||||
|
"Time step (min)": 30,
|
||||||
|
});
|
||||||
|
assert.deepEqual(newScenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [30, 30] },
|
||||||
|
b2: { "Load (MW)": [10, 20] },
|
||||||
|
b3: { "Load (MW)": [0, 30] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTimeHorizon grow", () => {
|
||||||
|
const [newScenario, err] = changeTimeHorizon(TEST_DATA_1, "7");
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(newScenario.Parameters, {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 7,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
});
|
||||||
|
assert.deepEqual(newScenario.Buses, {
|
||||||
|
b1: {
|
||||||
|
"Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044, 0, 0],
|
||||||
|
},
|
||||||
|
b2: {
|
||||||
|
"Load (MW)": [14.03739, 13.48563, 13.11797, 12.9009, 13.03939, 0, 0],
|
||||||
|
},
|
||||||
|
b3: {
|
||||||
|
"Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268, 0, 0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTimeHorizon invalid", () => {
|
||||||
|
let [, err] = changeTimeHorizon(TEST_DATA_1, "x");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Invalid value: x");
|
||||||
|
|
||||||
|
[, err] = changeTimeHorizon(TEST_DATA_1, "-3");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Invalid value: -3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluatePwlFunction", () => {
|
||||||
|
const data_x = [0, 60, 120, 180];
|
||||||
|
const data_y = [100, 200, 250, 100];
|
||||||
|
assert.equal(evaluatePwlFunction(data_x, data_y, 0), 100);
|
||||||
|
assert.equal(evaluatePwlFunction(data_x, data_y, 15), 125);
|
||||||
|
assert.equal(evaluatePwlFunction(data_x, data_y, 30), 150);
|
||||||
|
assert.equal(evaluatePwlFunction(data_x, data_y, 60), 200);
|
||||||
|
assert.equal(evaluatePwlFunction(data_x, data_y, 180), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTimeStep", () => {
|
||||||
|
let [scenario, err] = changeTimeStep(TEST_DATA_2, "15");
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(scenario.Parameters, {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 2,
|
||||||
|
"Time step (min)": 15,
|
||||||
|
});
|
||||||
|
assert.deepEqual(scenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [30, 30, 30, 30, 30, 30, 30, 30] },
|
||||||
|
b2: { "Load (MW)": [10, 15, 20, 25, 30, 35, 40, 25] },
|
||||||
|
b3: { "Load (MW)": [0, 15, 30, 15, 0, 20, 40, 20] },
|
||||||
|
});
|
||||||
|
|
||||||
|
[scenario, err] = changeTimeStep(TEST_DATA_2, "60");
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(scenario.Parameters, {
|
||||||
|
Version: "0.4",
|
||||||
|
"Power balance penalty ($/MW)": 1000.0,
|
||||||
|
"Time horizon (h)": 2,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
});
|
||||||
|
assert.deepEqual(scenario.Buses, {
|
||||||
|
b1: { "Load (MW)": [30, 30] },
|
||||||
|
b2: { "Load (MW)": [10, 30] },
|
||||||
|
b3: { "Load (MW)": [0, 0] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTimeStep invalid", () => {
|
||||||
|
let [, err] = changeTimeStep(TEST_DATA_2, "x");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Invalid value: x");
|
||||||
|
|
||||||
|
[, err] = changeTimeStep(TEST_DATA_2, "-10");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Invalid value: -10");
|
||||||
|
|
||||||
|
[, err] = changeTimeStep(TEST_DATA_2, "120");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Invalid value: 120");
|
||||||
|
|
||||||
|
[, err] = changeTimeStep(TEST_DATA_2, "7");
|
||||||
|
assert(err !== null);
|
||||||
|
assert.equal(err.message, "Time step must be a divisor of 60: 7");
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
||||||
221
web/frontend/src/core/Operations/parameterOps.ts
Normal file
221
web/frontend/src/core/Operations/parameterOps.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from "../Data/fixtures";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
import { UnitCommitmentScenario } from "../Data/types";
|
||||||
|
|
||||||
|
export const changeTimeHorizon = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
newTimeHorizonStr: string,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
// Parse string
|
||||||
|
const newTimeHorizon = parseInt(newTimeHorizonStr);
|
||||||
|
if (isNaN(newTimeHorizon) || newTimeHorizon <= 0) {
|
||||||
|
return [scenario, { message: `Invalid value: ${newTimeHorizonStr}` }];
|
||||||
|
}
|
||||||
|
const newScenario = JSON.parse(
|
||||||
|
JSON.stringify(scenario),
|
||||||
|
) as UnitCommitmentScenario;
|
||||||
|
newScenario.Parameters["Time horizon (h)"] = newTimeHorizon;
|
||||||
|
const newT = (newTimeHorizon * 60) / scenario.Parameters["Time step (min)"];
|
||||||
|
const oldT =
|
||||||
|
(scenario.Parameters["Time horizon (h)"] * 60) /
|
||||||
|
scenario.Parameters["Time step (min)"];
|
||||||
|
if (newT < oldT) {
|
||||||
|
Object.values(newScenario.Buses).forEach((bus) => {
|
||||||
|
bus["Load (MW)"] = bus["Load (MW)"].slice(0, newT);
|
||||||
|
});
|
||||||
|
Object.values(newScenario.Generators).forEach((generator) => {
|
||||||
|
if (generator.Type === "Profiled") {
|
||||||
|
generator["Minimum power (MW)"] = generator["Minimum power (MW)"].slice(0, newT);
|
||||||
|
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) => {
|
||||||
|
bus["Load (MW)"] = bus["Load (MW)"].concat(padding);
|
||||||
|
});
|
||||||
|
Object.values(newScenario.Generators).forEach((generator) => {
|
||||||
|
if (generator.Type === "Profiled") {
|
||||||
|
generator["Minimum power (MW)"] = generator["Minimum power (MW)"].concat(padding);
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluatePwlFunction = (
|
||||||
|
data_x: number[],
|
||||||
|
data_y: number[],
|
||||||
|
x: number,
|
||||||
|
) => {
|
||||||
|
if (x < data_x[0]! || x > data_x[data_x.length - 1]!) {
|
||||||
|
throw Error("PWL interpolation: Out of bounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x === data_x[0]) return data_y[0];
|
||||||
|
|
||||||
|
// Binary search to find the interval containing x
|
||||||
|
let low = 0;
|
||||||
|
let high = data_x.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
let mid = Math.floor((low + high) / 2);
|
||||||
|
if (data_x[mid]! < x) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear interpolation within the found interval
|
||||||
|
const x1 = data_x[low - 1]!;
|
||||||
|
const y1 = data_y[low - 1]!;
|
||||||
|
const x2 = data_x[low]!;
|
||||||
|
const y2 = data_y[low]!;
|
||||||
|
|
||||||
|
return y1 + ((x - x1) * (y2 - y1)) / (x2 - x1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeTimeStep = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
newTimeStepStr: string,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
// Parse string and perform validation
|
||||||
|
const newTimeStep = parseFloat(newTimeStepStr);
|
||||||
|
if (isNaN(newTimeStep) || newTimeStep < 1 || newTimeStep > 60) {
|
||||||
|
return [scenario, { message: `Invalid value: ${newTimeStepStr}` }];
|
||||||
|
}
|
||||||
|
if (60 % newTimeStep !== 0) {
|
||||||
|
return [
|
||||||
|
scenario,
|
||||||
|
{ message: `Time step must be a divisor of 60: ${newTimeStepStr}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build data_x
|
||||||
|
let timeHorizon = scenario.Parameters["Time horizon (h)"];
|
||||||
|
const oldTimeStep = scenario.Parameters["Time step (min)"];
|
||||||
|
const oldT = (timeHorizon * 60) / oldTimeStep;
|
||||||
|
const newT = (timeHorizon * 60) / newTimeStep;
|
||||||
|
const data_x = Array(oldT + 1).fill(0);
|
||||||
|
for (let i = 0; i <= oldT; i++) data_x[i] = i * oldTimeStep;
|
||||||
|
|
||||||
|
const newBuses: Buses = {};
|
||||||
|
for (const busName in scenario.Buses) {
|
||||||
|
// Build data_y
|
||||||
|
const busLoad = scenario.Buses[busName]!["Load (MW)"];
|
||||||
|
const data_y = Array(oldT + 1).fill(0);
|
||||||
|
for (let i = 0; i < oldT; i++) data_y[i] = busLoad[i];
|
||||||
|
data_y[oldT] = data_y[0];
|
||||||
|
|
||||||
|
// Run interpolation
|
||||||
|
const newBusLoad = Array(newT).fill(0);
|
||||||
|
for (let i = 0; i < newT; i++) {
|
||||||
|
newBusLoad[i] = evaluatePwlFunction(data_x, data_y, newTimeStep * i);
|
||||||
|
}
|
||||||
|
newBuses[busName] = {
|
||||||
|
...scenario.Buses[busName],
|
||||||
|
"Load (MW)": newBusLoad,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGenerators: { [name: string]: any } = {};
|
||||||
|
for (const generatorName in scenario.Generators) {
|
||||||
|
const generator = scenario.Generators[generatorName]!;
|
||||||
|
if (generator.Type === "Profiled") {
|
||||||
|
// Build data_y for minimum power
|
||||||
|
const minPower = generator["Minimum power (MW)"];
|
||||||
|
const minData_y = Array(oldT + 1).fill(0);
|
||||||
|
for (let i = 0; i < oldT; i++) minData_y[i] = minPower[i];
|
||||||
|
minData_y[oldT] = minData_y[0];
|
||||||
|
|
||||||
|
// Build data_y for maximum power
|
||||||
|
const maxPower = generator["Maximum power (MW)"];
|
||||||
|
const maxData_y = Array(oldT + 1).fill(0);
|
||||||
|
for (let i = 0; i < oldT; i++) maxData_y[i] = maxPower[i];
|
||||||
|
maxData_y[oldT] = maxData_y[0];
|
||||||
|
|
||||||
|
// Run interpolation for both
|
||||||
|
const newMinPower = Array(newT).fill(0);
|
||||||
|
const newMaxPower = Array(newT).fill(0);
|
||||||
|
for (let i = 0; i < newT; i++) {
|
||||||
|
newMinPower[i] = evaluatePwlFunction(data_x, minData_y, newTimeStep * i);
|
||||||
|
newMaxPower[i] = evaluatePwlFunction(data_x, maxData_y, newTimeStep * i);
|
||||||
|
}
|
||||||
|
|
||||||
|
newGenerators[generatorName] = {
|
||||||
|
...generator,
|
||||||
|
"Minimum power (MW)": newMinPower,
|
||||||
|
"Maximum power (MW)": newMaxPower,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newGenerators[generatorName] = generator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Parameters: {
|
||||||
|
...scenario.Parameters,
|
||||||
|
"Time step (min)": newTimeStep,
|
||||||
|
},
|
||||||
|
Buses: newBuses,
|
||||||
|
Generators: newGenerators,
|
||||||
|
"Price-sensitive loads": newPriceSensitiveLoads,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeParameter = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
key: string,
|
||||||
|
valueStr: string,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const value = parseFloat(valueStr);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
return [scenario, { message: `Invalid value: ${valueStr}` }];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
Parameters: {
|
||||||
|
...scenario.Parameters,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
46
web/frontend/src/core/Operations/preprocessing.test.ts
Normal file
46
web/frontend/src/core/Operations/preprocessing.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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 { preprocess } from "./preprocessing";
|
||||||
|
|
||||||
|
export const PREPROCESSING_TEST_DATA_1: any = {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Time horizon (h)": 5,
|
||||||
|
},
|
||||||
|
Buses: {
|
||||||
|
b1: { "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044] },
|
||||||
|
b2: { "Load (MW)": 10 },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("preprocess", () => {
|
||||||
|
const [newScenario, err] = preprocess(PREPROCESSING_TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(newScenario, {
|
||||||
|
Parameters: {
|
||||||
|
Version: "0.4",
|
||||||
|
"Time horizon (h)": 5,
|
||||||
|
"Power balance penalty ($/MW)": 1000,
|
||||||
|
"Scenario name": "s1",
|
||||||
|
"Scenario weight": 1,
|
||||||
|
"Time step (min)": 60,
|
||||||
|
},
|
||||||
|
Buses: {
|
||||||
|
b1: { "Load (MW)": [35.79534, 34.38835, 33.45083, 32.89729, 33.25044] },
|
||||||
|
b2: { "Load (MW)": [10, 10, 10, 10, 10] },
|
||||||
|
b3: { "Load (MW)": [27.3729, 26.29698, 25.58005, 25.15675, 25.4268] },
|
||||||
|
},
|
||||||
|
"Price-sensitive loads": {},
|
||||||
|
"Storage units": {},
|
||||||
|
"Transmission lines": {},
|
||||||
|
Contingencies: {},
|
||||||
|
Generators: {},
|
||||||
|
Reserves: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
70
web/frontend/src/core/Operations/preprocessing.ts
Normal file
70
web/frontend/src/core/Operations/preprocessing.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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 { validate, ValidationError } from "../Data/validate";
|
||||||
|
import { UnitCommitmentScenario } from "../Data/types";
|
||||||
|
import { migrate } from "../Data/migrate";
|
||||||
|
import {
|
||||||
|
getContingencyTransmissionLines,
|
||||||
|
rebuildContingencies,
|
||||||
|
} from "./transmissionOps";
|
||||||
|
|
||||||
|
export const preprocess = (
|
||||||
|
data: any,
|
||||||
|
): [UnitCommitmentScenario | null, ValidationError | null] => {
|
||||||
|
// Make a copy of the original data
|
||||||
|
let result = JSON.parse(JSON.stringify(data));
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate(result);
|
||||||
|
|
||||||
|
// Run JSON validation and assign default values
|
||||||
|
if (!validate(result)) {
|
||||||
|
console.error(validate.errors);
|
||||||
|
return [
|
||||||
|
null,
|
||||||
|
{ message: "Invalid JSON file. See console for more details." },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand scalars into arrays
|
||||||
|
// @ts-ignore
|
||||||
|
const timeHorizon = result["Parameters"]["Time horizon (h)"];
|
||||||
|
// @ts-ignore
|
||||||
|
const timeStep = result["Parameters"]["Time step (min)"];
|
||||||
|
const T = (timeHorizon * 60) / timeStep;
|
||||||
|
for (const busName in result["Buses"]) {
|
||||||
|
// @ts-ignore
|
||||||
|
const busData = result["Buses"][busName];
|
||||||
|
const busLoad = busData["Load (MW)"];
|
||||||
|
if (typeof busLoad === "number") {
|
||||||
|
busData["Load (MW)"] = Array(T).fill(busLoad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional fields
|
||||||
|
for (let field of [
|
||||||
|
"Buses",
|
||||||
|
"Generators",
|
||||||
|
"Storage units",
|
||||||
|
"Price-sensitive loads",
|
||||||
|
"Transmission lines",
|
||||||
|
"Reserves",
|
||||||
|
"Contingencies",
|
||||||
|
]) {
|
||||||
|
if (!result[field]) {
|
||||||
|
result[field] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenario = result as unknown as UnitCommitmentScenario;
|
||||||
|
|
||||||
|
// Rebuild contingencies
|
||||||
|
const contingencyLines = getContingencyTransmissionLines(scenario);
|
||||||
|
scenario["Contingencies"] = rebuildContingencies(contingencyLines);
|
||||||
|
|
||||||
|
return [scenario, null];
|
||||||
|
};
|
||||||
60
web/frontend/src/core/Operations/psloadOps.test.ts
Normal file
60
web/frontend/src/core/Operations/psloadOps.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
88
web/frontend/src/core/Operations/psloadOps.ts
Normal file
88
web/frontend/src/core/Operations/psloadOps.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
75
web/frontend/src/core/Operations/storageOps.test.ts
Normal file
75
web/frontend/src/core/Operations/storageOps.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
changeStorageUnitData,
|
||||||
|
createStorageUnit,
|
||||||
|
deleteStorageUnit,
|
||||||
|
renameStorageUnit,
|
||||||
|
} from "./storageOps";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
|
||||||
|
test("createStorageUnit", () => {
|
||||||
|
const [newScenario, err] = createStorageUnit(TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.equal(Object.keys(newScenario["Storage units"]).length, 2);
|
||||||
|
assert("su2" in newScenario["Storage units"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renameStorageUnit", () => {
|
||||||
|
const [newScenario, err] = renameStorageUnit("su1", "su2", TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(
|
||||||
|
newScenario["Storage units"]["su2"],
|
||||||
|
TEST_DATA_1["Storage units"]["su1"],
|
||||||
|
);
|
||||||
|
assert.equal(Object.keys(newScenario["Storage units"]).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeStorageUnitData", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
let err: ValidationError | null;
|
||||||
|
[scenario, err] = changeStorageUnitData("su1", "Bus", "b3", scenario);
|
||||||
|
assert.equal(err, null);
|
||||||
|
[scenario, err] = changeStorageUnitData(
|
||||||
|
"su1",
|
||||||
|
"Minimum level (MWh)",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert.equal(err, null);
|
||||||
|
[scenario, err] = changeStorageUnitData(
|
||||||
|
"su1",
|
||||||
|
"Maximum discharge rate (MW)",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert.equal(err, null);
|
||||||
|
assert.deepEqual(scenario["Storage units"]["su1"], {
|
||||||
|
Bus: "b3",
|
||||||
|
"Minimum level (MWh)": 99.0,
|
||||||
|
"Maximum level (MWh)": 100.0,
|
||||||
|
"Charge cost ($/MW)": 2.0,
|
||||||
|
"Discharge cost ($/MW)": 1.0,
|
||||||
|
"Charge efficiency": 0.8,
|
||||||
|
"Discharge efficiency": 0.85,
|
||||||
|
"Loss factor": 0.01,
|
||||||
|
"Minimum charge rate (MW)": 5.0,
|
||||||
|
"Maximum charge rate (MW)": 10.0,
|
||||||
|
"Minimum discharge rate (MW)": 4.0,
|
||||||
|
"Maximum discharge rate (MW)": 99.0,
|
||||||
|
"Initial level (MWh)": 20.0,
|
||||||
|
"Last period minimum level (MWh)": 21.0,
|
||||||
|
"Last period maximum level (MWh)": 22.0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleteStorageUnit", () => {
|
||||||
|
const newScenario = deleteStorageUnit("su1", TEST_DATA_1);
|
||||||
|
assert.equal(Object.keys(newScenario["Storage units"]).length, 0);
|
||||||
|
});
|
||||||
98
web/frontend/src/core/Operations/storageOps.ts
Normal file
98
web/frontend/src/core/Operations/storageOps.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* 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 { StorageUnit, UnitCommitmentScenario } from "../Data/types";
|
||||||
|
import {
|
||||||
|
assertBusesNotEmpty,
|
||||||
|
changeData,
|
||||||
|
generateUniqueName,
|
||||||
|
renameItemInObject,
|
||||||
|
} from "./commonOps";
|
||||||
|
import { StorageUnitsColumnSpec } from "../../components/CaseBuilder/StorageUnits";
|
||||||
|
|
||||||
|
export const createStorageUnit = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const err = assertBusesNotEmpty(scenario);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
const busName = Object.keys(scenario.Buses)[0]!;
|
||||||
|
const name = generateUniqueName(scenario["Storage units"], "su");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
"Storage units": {
|
||||||
|
...scenario["Storage units"],
|
||||||
|
[name]: {
|
||||||
|
Bus: busName,
|
||||||
|
"Minimum level (MWh)": 0,
|
||||||
|
"Maximum level (MWh)": 1,
|
||||||
|
"Charge cost ($/MW)": 0.0,
|
||||||
|
"Discharge cost ($/MW)": 0.0,
|
||||||
|
"Charge efficiency": 1,
|
||||||
|
"Discharge efficiency": 1,
|
||||||
|
"Loss factor": 0,
|
||||||
|
"Minimum charge rate (MW)": 1,
|
||||||
|
"Maximum charge rate (MW)": 1,
|
||||||
|
"Minimum discharge rate (MW)": 1,
|
||||||
|
"Maximum discharge rate (MW)": 1,
|
||||||
|
"Initial level (MWh)": 0,
|
||||||
|
"Last period minimum level (MWh)": 0,
|
||||||
|
"Last period maximum level (MWh)": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameStorageUnit = (
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newObj, err] = renameItemInObject(
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
scenario["Storage units"],
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
return [{ ...scenario, "Storage units": newObj }, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeStorageUnitData = (
|
||||||
|
name: string,
|
||||||
|
field: string,
|
||||||
|
newValueStr: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newObj, err] = changeData(
|
||||||
|
field,
|
||||||
|
newValueStr,
|
||||||
|
scenario["Storage units"][name]!,
|
||||||
|
StorageUnitsColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
"Storage units": {
|
||||||
|
...scenario["Storage units"],
|
||||||
|
[name]: newObj as StorageUnit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteStorageUnit = (
|
||||||
|
name: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): UnitCommitmentScenario => {
|
||||||
|
const { [name]: _, ...newContainer } = scenario["Storage units"];
|
||||||
|
return { ...scenario, "Storage units": newContainer };
|
||||||
|
};
|
||||||
102
web/frontend/src/core/Operations/transmissionOps.test.ts
Normal file
102
web/frontend/src/core/Operations/transmissionOps.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
changeTransmissionLineData,
|
||||||
|
createTransmissionLine,
|
||||||
|
deleteTransmissionLine,
|
||||||
|
getContingencyTransmissionLines,
|
||||||
|
rebuildContingencies,
|
||||||
|
renameTransmissionLine,
|
||||||
|
} from "./transmissionOps";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
|
||||||
|
test("createTransmissionLine", () => {
|
||||||
|
const [newScenario, err] = createTransmissionLine(TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.equal(Object.keys(newScenario["Transmission lines"]).length, 2);
|
||||||
|
assert("l2" in newScenario["Transmission lines"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renameTransmissionLine", () => {
|
||||||
|
const [newScenario, err] = renameTransmissionLine("l1", "l3", TEST_DATA_1);
|
||||||
|
assert(err === null);
|
||||||
|
assert.deepEqual(newScenario["Transmission lines"]["l3"], {
|
||||||
|
"Source bus": "b1",
|
||||||
|
"Target bus": "b2",
|
||||||
|
"Susceptance (S)": 29.49686,
|
||||||
|
"Normal flow limit (MW)": 15000.0,
|
||||||
|
"Emergency flow limit (MW)": 20000.0,
|
||||||
|
"Flow limit penalty ($/MW)": 5000.0,
|
||||||
|
});
|
||||||
|
assert.deepEqual(newScenario["Contingencies"], {
|
||||||
|
l3: {
|
||||||
|
"Affected lines": ["l3"],
|
||||||
|
"Affected generators": [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(Object.keys(newScenario["Transmission lines"]).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changeTransmissionLineData", () => {
|
||||||
|
let scenario = TEST_DATA_1;
|
||||||
|
let err: ValidationError | null;
|
||||||
|
[scenario, err] = changeTransmissionLineData(
|
||||||
|
"l1",
|
||||||
|
"Source bus",
|
||||||
|
"b3",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert.equal(err, null);
|
||||||
|
[scenario, err] = changeTransmissionLineData(
|
||||||
|
"l1",
|
||||||
|
"Normal flow limit (MW)",
|
||||||
|
"99",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert.equal(err, null);
|
||||||
|
[scenario, err] = changeTransmissionLineData(
|
||||||
|
"l1",
|
||||||
|
"Target bus",
|
||||||
|
"b1",
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
assert.equal(err, null);
|
||||||
|
assert.deepEqual(scenario["Transmission lines"]["l1"], {
|
||||||
|
"Source bus": "b3",
|
||||||
|
"Target bus": "b1",
|
||||||
|
"Susceptance (S)": 29.49686,
|
||||||
|
"Normal flow limit (MW)": 99,
|
||||||
|
"Emergency flow limit (MW)": 20000.0,
|
||||||
|
"Flow limit penalty ($/MW)": 5000.0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleteTransmissionLine", () => {
|
||||||
|
const newScenario = deleteTransmissionLine("l1", TEST_DATA_1);
|
||||||
|
assert.equal(Object.keys(newScenario["Transmission lines"]).length, 0);
|
||||||
|
assert.equal(Object.keys(newScenario["Contingencies"]).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getContingencyTransmissionLines", () => {
|
||||||
|
const contLines = getContingencyTransmissionLines(TEST_DATA_1);
|
||||||
|
assert.deepEqual(contLines, new Set(["l1"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rebuildContingencies", () => {
|
||||||
|
assert.deepEqual(rebuildContingencies(new Set(["l1", "l2"])), {
|
||||||
|
l1: {
|
||||||
|
"Affected lines": ["l1"],
|
||||||
|
"Affected generators": [],
|
||||||
|
},
|
||||||
|
l2: {
|
||||||
|
"Affected lines": ["l2"],
|
||||||
|
"Affected generators": [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
163
web/frontend/src/core/Operations/transmissionOps.ts
Normal file
163
web/frontend/src/core/Operations/transmissionOps.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
assertBusesNotEmpty,
|
||||||
|
changeData,
|
||||||
|
generateUniqueName,
|
||||||
|
parseBool,
|
||||||
|
renameItemInObject,
|
||||||
|
} from "./commonOps";
|
||||||
|
import { ValidationError } from "../Data/validate";
|
||||||
|
import { TransmissionLinesColumnSpec } from "../../components/CaseBuilder/TransmissionLines";
|
||||||
|
import {
|
||||||
|
Contingency,
|
||||||
|
TransmissionLine,
|
||||||
|
UnitCommitmentScenario,
|
||||||
|
} from "../Data/types";
|
||||||
|
|
||||||
|
export const createTransmissionLine = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const err = assertBusesNotEmpty(scenario);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
const busName = Object.keys(scenario.Buses)[0]!;
|
||||||
|
const name = generateUniqueName(scenario["Transmission lines"], "l");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
"Transmission lines": {
|
||||||
|
...scenario["Transmission lines"],
|
||||||
|
[name]: {
|
||||||
|
"Source bus": busName,
|
||||||
|
"Target bus": busName,
|
||||||
|
"Susceptance (S)": 1.0,
|
||||||
|
"Normal flow limit (MW)": 1000,
|
||||||
|
"Emergency flow limit (MW)": 1500,
|
||||||
|
"Flow limit penalty ($/MW)": 5000.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameTransmissionLine = (
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
const [newLine, err] = renameItemInObject(
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
scenario["Transmission lines"],
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
|
||||||
|
// Update transmission line contingencies
|
||||||
|
let newContingencies = scenario["Contingencies"];
|
||||||
|
const contingencyLines = getContingencyTransmissionLines(scenario);
|
||||||
|
if (contingencyLines.has(oldName)) {
|
||||||
|
contingencyLines.delete(oldName);
|
||||||
|
contingencyLines.add(newName);
|
||||||
|
newContingencies = rebuildContingencies(contingencyLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
"Transmission lines": newLine,
|
||||||
|
Contingencies: newContingencies,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeTransmissionLineData = (
|
||||||
|
line: string,
|
||||||
|
field: string,
|
||||||
|
newValueStr: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): [UnitCommitmentScenario, ValidationError | null] => {
|
||||||
|
if (field === "Contingency?") {
|
||||||
|
// Parse boolean value
|
||||||
|
const [newValue, err] = parseBool(newValueStr);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
|
||||||
|
// Rebuild contingencies
|
||||||
|
const contLines = getContingencyTransmissionLines(scenario);
|
||||||
|
if (newValue) contLines.add(line);
|
||||||
|
else contLines.delete(line);
|
||||||
|
const newContingencies = rebuildContingencies(contLines);
|
||||||
|
|
||||||
|
return [{ ...scenario, Contingencies: newContingencies }, null];
|
||||||
|
} else {
|
||||||
|
const [newLine, err] = changeData(
|
||||||
|
field,
|
||||||
|
newValueStr,
|
||||||
|
scenario["Transmission lines"][line]!,
|
||||||
|
TransmissionLinesColumnSpec,
|
||||||
|
scenario,
|
||||||
|
);
|
||||||
|
if (err) return [scenario, err];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...scenario,
|
||||||
|
"Transmission lines": {
|
||||||
|
...scenario["Transmission lines"],
|
||||||
|
[line]: newLine as TransmissionLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTransmissionLine = (
|
||||||
|
name: string,
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): UnitCommitmentScenario => {
|
||||||
|
const { [name]: _, ...newLines } = scenario["Transmission lines"];
|
||||||
|
|
||||||
|
// Update transmission line contingencies
|
||||||
|
let newContingencies = scenario["Contingencies"];
|
||||||
|
const contingencyLines = getContingencyTransmissionLines(scenario);
|
||||||
|
if (contingencyLines.has(name)) {
|
||||||
|
contingencyLines.delete(name);
|
||||||
|
newContingencies = rebuildContingencies(contingencyLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scenario,
|
||||||
|
"Transmission lines": newLines,
|
||||||
|
Contingencies: newContingencies,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getContingencyTransmissionLines = (
|
||||||
|
scenario: UnitCommitmentScenario,
|
||||||
|
): Set<String> => {
|
||||||
|
let result: Set<String> = new Set();
|
||||||
|
Object.entries(scenario.Contingencies).forEach(([name, contingency]) => {
|
||||||
|
if (contingency["Affected lines"].length !== 1)
|
||||||
|
throw Error("not implemented");
|
||||||
|
result.add(contingency["Affected lines"][0]!!);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rebuildContingencies = (
|
||||||
|
contingencyLines: Set<String>,
|
||||||
|
): { [name: string]: Contingency } => {
|
||||||
|
const result: { [name: string]: Contingency } = {};
|
||||||
|
contingencyLines.forEach((lineName) => {
|
||||||
|
result[lineName as string] = {
|
||||||
|
"Affected lines": [lineName as string],
|
||||||
|
"Affected generators": [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
22
web/frontend/src/index.tsx
Normal file
22
web/frontend/src/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* 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 React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
import CaseBuilder from "./components/CaseBuilder/CaseBuilder";
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById("root") as HTMLElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<CaseBuilder />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
|
reportWebVitals();
|
||||||
13
web/frontend/src/logo.svg
Normal file
13
web/frontend/src/logo.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!--
|
||||||
|
- 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||||
|
<g fill="#61DAFB">
|
||||||
|
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||||
|
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||||
|
<path d="M520.5 78.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
7
web/frontend/src/react-app-env.d.ts
vendored
Normal file
7
web/frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// <reference types="react-scripts" />
|
||||||
21
web/frontend/src/reportWebVitals.ts
Normal file
21
web/frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* 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 { ReportHandler } from "web-vitals";
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
7
web/frontend/src/setupTests.ts
Normal file
7
web/frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
* 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 "@testing-library/jest-dom";
|
||||||
35
web/frontend/tsconfig.json
Normal file
35
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user