From 55b3d1c77e75b31079096fdfacdd40df0a8575de Mon Sep 17 00:00:00 2001 From: lixiangk1 <72464565+lixiangk1@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:21:25 -0600 Subject: [PATCH 01/28] Add initial MPC implementation --- julia_src/http.jl | 52 +- julia_src/mpc.jl | 655 ++++++++++++++++++ .../migrations/0117_merge_20260530_1640.py | 14 + 3 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 julia_src/mpc.jl create mode 100644 reoptjl/migrations/0117_merge_20260530_1640.py diff --git a/julia_src/http.jl b/julia_src/http.jl index 9eab6396d..e6f94a42c 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -1,4 +1,4 @@ -using HTTP, JSON, JuMP +using HTTP, JSON, JuMP using HiGHS, Cbc, SCIP using GhpGhx import REopt as reoptjl # For REopt.jl, needed because we still have local REopt.jl module for V1/V2 @@ -8,6 +8,7 @@ DotEnv.load!() const test_nrel_developer_api_key = ENV["NREL_DEVELOPER_API_KEY"] ENV["NREL_DEVELOPER_EMAIL"] = "reopt@nlr.gov" +include("mpc.jl") include("os_solvers.jl") @@ -64,6 +65,27 @@ function reopt(req::HTTP.Request) ENV["NREL_DEVELOPER_API_KEY"] = test_nrel_developer_api_key delete!(d, "api_key") end + + # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- + # When ElectricStorage.dispatch_options == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. + # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. + electric_storage = get(d, "ElectricStorage", Dict()) + if get(electric_storage, "dispatch_options", nothing) == "daily_foresight_optimized" + try + @info "Running MPC to obtain daily foresight optimized battery dispatch profile." + mpc_results = get_mpc_results(d) + soc = mpc_results["dispatch"]["ElectricStorage"]["soc_series_fraction"] + d["ElectricStorage"]["fixed_soc_series_fraction"] = soc + d["ElectricStorage"]["dispatch_options"] = "custom" + catch e + @error "MPC pre-solve failed" exception=(e, catch_backtrace()) + return HTTP.Response(500, JSON.json(Dict( + "error" => "MPC pre-solve failed: " * sprint(showerror, e), + "reopt_version" => string(pkgversion(reoptjl)), + ))) + end + end + settings = d["Settings"] solver_name = get(settings, "solver_name", "HiGHS") if solver_name == "Xpress" && !(xpress_installed=="True") @@ -810,6 +832,33 @@ function job_no_xpress(req::HTTP.Request) return HTTP.Response(500, JSON.json(error_response)) end + +function mpc(req::HTTP.Request) + d = JSON.parse(String(req.body)) + error_response = Dict() + results = Dict() + try + if !isempty(get(d, "api_key", "")) + ENV["NREL_DEVELOPER_API_KEY"] = pop!(d, "api_key") + else + ENV["NREL_DEVELOPER_API_KEY"] = test_nrel_developer_api_key + delete!(d, "api_key") + end + results = get_mpc_results(d) + catch e + @error "MPC failed" exception=(e, catch_backtrace()) + error_response["error"] = sprint(showerror, e) + error_response["reopt_version"] = string(pkgversion(reoptjl)) + end + GC.gc() + if isempty(error_response) + @info "MPC ran successfully." + return HTTP.Response(200, JSON.json(results)) + else + return HTTP.Response(500, JSON.json(error_response)) + end +end + # define REST endpoints to dispatch to "service" functions const ROUTER = HTTP.Router() @@ -820,6 +869,7 @@ else end HTTP.register!(ROUTER, "POST", "/reopt", reopt) HTTP.register!(ROUTER, "POST", "/erp", erp) +HTTP.register!(ROUTER, "POST", "/mpc", mpc) HTTP.register!(ROUTER, "POST", "/ghpghx", ghpghx) HTTP.register!(ROUTER, "GET", "/chp_defaults", chp_defaults) HTTP.register!(ROUTER, "GET", "/avert_emissions_profile", avert_emissions_profile) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl new file mode 100644 index 000000000..21cb9510a --- /dev/null +++ b/julia_src/mpc.jl @@ -0,0 +1,655 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NatLabRockies/REopt_API/blob/master/LICENSE. +# ============================================================================ +# MPC (Model Predictive Control) endpoint +# ---------------------------------------------------------------------------- +# Rolling-horizon dispatch with one day look-ahead using REopt.run_mpc. Assumes: +# * PV and ElectricStorage are the only available technologies (perfect forecast) +# * Only perfect forecast scenarios are modeled (no forecast errors) +# * Sizing: Perform a sizing run first if PV or ElectricStorage sizes are not provided (storage sizes must be greater than zero) +# * PV: Use PVWatts if `PV.production_factor_series` is not provided +# * ElectricLoad: Use commercial reference profiles if `ElectricLoad.loads_kw` is not provided +# * Code currently wraps with Jan 1 data to determine Dec 31 dispatch (allows leap-year data, which avoids the need to wrap) +# * MPC settings are currently hard coded (e.g., forecast horizon, control horizon, optimization horizon) +# ============================================================================ + +""" + get_month_transition_timesteps(time_steps_per_hour) + +Return the 1-based timestep index marking the START of each month for a +non-leap year beginning Jan 1 00:00. Length 12; first element == 1. +The last "transition" (end of December) is implicitly `length_of_data + 1` +and is handled by the caller. +""" +function get_month_transition_timesteps(time_steps_per_hour::Int) + # hours in each month for a non-leap year + hours_per_month = [744, 672, 744, 720, 744, 720, 744, 744, 720, 744, 720, 744] + starts = Vector{Int}(undef, 12) + starts[1] = 1 + for m in 2:12 + starts[m] = starts[m-1] + hours_per_month[m-1] * time_steps_per_hour + end + return starts +end + +""" + _mpc_slice_with_wrap(arr, idx, end_idx) + +Return `arr[idx:end_idx]` but wrap around to the start of `arr` if +`end_idx > length(arr)`. The wrap threshold is the actual array length, +not `length_of_data`, so a leap-year-length input (8784*tsh) naturally +supplies the extra day for the year-end look-ahead window before any +wrap is needed. +""" +function _mpc_slice_with_wrap(arr::AbstractVector, idx::Int, end_idx::Int) + n = length(arr) + if end_idx <= n + return arr[idx:end_idx] + else + wrap_len = end_idx - n + return vcat(arr[idx:n], arr[1:wrap_len]) + end +end + +""" + _mpc_check_series_length(name, series, time_steps_per_hour, length_of_data) + +Validate that a user-supplied annual time series has either +`8760 * time_steps_per_hour` entries (standard year) or +`8784 * time_steps_per_hour` entries (leap year). Returns the series +unchanged. + +The MPC loop iterates over `length_of_data = 8760 * time_steps_per_hour` +timesteps; if a leap-year-length series is supplied, the extra day is +used as additional look-ahead data by `_mpc_slice_with_wrap` (which wraps +on the actual array length, not on `length_of_data`). Any other length is +a hard error — REopt.jl's own `check_and_adjust_load_length` would +silently repeat/pad, masking the mistake. +""" +function _mpc_check_series_length(name::String, series::AbstractVector, + time_steps_per_hour::Int, length_of_data::Int) + n = length(series) + n == length_of_data && return series + if n == 8784 * time_steps_per_hour + @info "MPC: $name has leap-year length ($n); the extra day will be used " * + "for year-end look-ahead instead of wrapping to Jan 1." + return series + end + # Diagnostic: if `n` is consistent with 8760 hours at a *different* + # sub-hourly resolution, the user probably mis-declared + # `Settings.time_steps_per_hour`. + tsh_hint = "" + for tsh_guess in (1, 2, 4) + tsh_guess == time_steps_per_hour && continue + if n == 8760 * tsh_guess || n == 8784 * tsh_guess + tsh_hint = " (length is consistent with time_steps_per_hour=$(tsh_guess); " * + "check Settings.time_steps_per_hour)." + break + end + end + error("MPC: $name length $n != " * + "8760 * time_steps_per_hour ($length_of_data) " * + "and != 8784 * time_steps_per_hour ($(8784 * time_steps_per_hour))." * + tsh_hint) +end + +""" + _mpc_build_tariff_arrays(et_input, year, time_steps_per_hour, length_of_data) + +Construct a `REopt.ElectricTariff` from the user's ElectricTariff input dict +(which uses the real REopt input schema: `urdb_label`, `urdb_response`, +`urdb_utility_name`+`urdb_rate_name`, `tou_energy_rates_per_kwh`, +`monthly_energy_rates`, `monthly_demand_rates`, `blended_annual_energy_rate`, +`blended_annual_demand_rate`, etc.) and extract the processed arrays MPC +needs for its per-window posts. + +Returns a NamedTuple with: + * `energy_rates::Vector{Float64}` (length `length_of_data`) + * `monthly_demand_rates::Vector{Float64}` (length 12, all zeros if not set) + * `tou_demand_rates::Vector{Float64}` (one per ratchet; empty for non-URDB) + * `tou_demand_time_steps::Vector{Vector{Int}}` (full-year indices per ratchet) + +Multi-tier rates are flattened to a single tier (MPC does not model tiers). +""" +function _mpc_build_tariff_arrays(et_input::Dict, year::Union{Int,Nothing}, + time_steps_per_hour::Int, length_of_data::Int) + # Only forward known ElectricTariff kwargs so unrelated keys (e.g. a + # parsed API artifact) don't trip the constructor. + known_kwargs = (:urdb_label, :urdb_response, :urdb_utility_name, + :urdb_rate_name, :urdb_metadata, + :wholesale_rate, :export_rate_beyond_net_metering_limit, + :monthly_energy_rates, :monthly_demand_rates, + :blended_annual_energy_rate, :blended_annual_demand_rate, + :add_monthly_rates_to_urdb_rate, + :tou_energy_rates_per_kwh, :add_tou_energy_rates_to_urdb_rate, + :remove_tiers, :demand_lookback_months, + :demand_lookback_percent, :demand_lookback_range, + :coincident_peak_load_active_time_steps, + :coincident_peak_load_charge_per_kw) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in et_input + if Symbol(k) in known_kwargs) + # Force single-tier arrays so MPC sees a flat schedule; without this the + # constructor could return `length_of_data x n_tiers` and we'd silently + # only use one tier (and inconsistently across energy vs demand). + kwargs[:remove_tiers] = get(kwargs, :remove_tiers, true) + + tariff = reoptjl.ElectricTariff(; year = year, + time_steps_per_hour = time_steps_per_hour, + kwargs...) + + # All arrays are single-tier (we forced `remove_tiers=true`). + energy_rates = vec(Float64.(tariff.energy_rates[:, 1])) + length(energy_rates) == length_of_data || error( + "MPC: derived energy_rates length $(length(energy_rates)) != length_of_data $(length_of_data).") + + monthly_demand_rates = isempty(tariff.monthly_demand_rates) ? + zeros(Float64, 12) : vec(Float64.(tariff.monthly_demand_rates[:, 1])) + length(monthly_demand_rates) == 12 || error( + "MPC: derived monthly_demand_rates length $(length(monthly_demand_rates)) != 12.") + + tou_demand_rates = isempty(tariff.tou_demand_rates) ? + Float64[] : vec(Float64.(tariff.tou_demand_rates[:, 1])) + tou_demand_time_steps = [Int.(v) for v in tariff.tou_demand_ratchet_time_steps] + + return (energy_rates = energy_rates, + monthly_demand_rates = monthly_demand_rates, + tou_demand_rates = tou_demand_rates, + tou_demand_time_steps = tou_demand_time_steps) +end + +""" + _mpc_generate_pv_production_factor(d, time_steps_per_hour) + +Derive a PV production factor series via `REopt.get_production_factor` +(PVWatts) using `Site.latitude`/`Site.longitude` and the PV inputs in `d`. +Returns a `Vector{Float64}`. Throws if lat/lon are missing. +""" +function _mpc_generate_pv_production_factor(d::Dict, time_steps_per_hour::Int) + site = get(d, "Site", Dict()) + haskey(site, "latitude") || error( + "MPC: Site.latitude is required to derive PV.production_factor_series via PVWatts.") + haskey(site, "longitude") || error( + "MPC: Site.longitude is required to derive PV.production_factor_series via PVWatts.") + lat = Float64(site["latitude"]) + lon = Float64(site["longitude"]) + + @info "MPC: PV.production_factor_series not provided; deriving via REopt.jl PVWatts (lat=$(lat), lon=$(lon))." + + # Only forward known REopt.PV kwargs that affect the production factor. + pv = get(d, "PV", Dict()) + pv_pf_kwargs = (:array_type, :tilt, :module_type, :losses, :azimuth, :gcr, + :radius, :name, :location, :dc_ac_ratio, :inv_eff) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in pv + if Symbol(k) in pv_pf_kwargs) + pv_struct = reoptjl.PV(; latitude = lat, kwargs...) + derived = reoptjl.get_production_factor(pv_struct, lat, lon; + time_steps_per_hour = time_steps_per_hour) + return collect(Float64.(derived)) +end + +""" + _mpc_generate_loads_kw(d, time_steps_per_hour) + +Derive an electric load profile via `REopt.ElectricLoad` from the user's +standard ElectricLoad inputs (`doe_reference_name`+`city` / +`blended_doe_reference_names`+`blended_doe_reference_percents`, +`annual_kwh`, `monthly_totals_kwh`, `monthly_peaks_kw`, `year`, etc.) — +the same DOE CRB derivation path the main REopt run would take. Returns a +`Vector{Float64}`. Requires `Site.latitude`/`Site.longitude`. +""" +function _mpc_generate_loads_kw(d::Dict, time_steps_per_hour::Int) + site = get(d, "Site", Dict()) + haskey(site, "latitude") || error( + "MPC: Site.latitude is required to derive ElectricLoad.loads_kw from a DOE CRB profile.") + haskey(site, "longitude") || error( + "MPC: Site.longitude is required to derive ElectricLoad.loads_kw from a DOE CRB profile.") + lat = Float64(site["latitude"]) + lon = Float64(site["longitude"]) + + @info "MPC: ElectricLoad.loads_kw not provided; deriving via REopt.jl ElectricLoad (lat=$(lat), lon=$(lon))." + + # Only forward known ElectricLoad kwargs. + eload = get(d, "ElectricLoad", Dict()) + known_kwargs = (:normalize_and_scale_load_profile_input, + :path_to_csv, :doe_reference_name, + :blended_doe_reference_names, :blended_doe_reference_percents, + :year, :city, :annual_kwh, :monthly_totals_kwh, + :monthly_peaks_kw, :critical_loads_kw, :loads_kw_is_net, + :critical_loads_kw_is_net, :critical_load_fraction, + :operating_reserve_required_fraction, + :min_load_met_annual_fraction, :off_grid_flag) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in eload + if Symbol(k) in known_kwargs) + load_struct = reoptjl.ElectricLoad(; latitude = lat, longitude = lon, + time_steps_per_hour = time_steps_per_hour, + kwargs...) + return collect(Float64.(load_struct.loads_kw)) +end + +""" + _mpc_resolve_sizes!(d) + +Determines PV/ElectricStorage sizes for the MPC loop, using the actual +REopt input schema (`min_kw`/`max_kw`, `min_kwh`/`max_kwh`). + +Rules: + * If `min_kw == max_kw` for PV AND `min_kw == max_kw` AND + `min_kwh == max_kwh` for ElectricStorage → sizes are already fixed; use + those values directly. No sizing run. + * Otherwise → run a regular REopt sizing optimization, then mutate `d` to + set `min_kw = max_kw = sized_kw` (and similarly for kWh on storage). + Locking sizes in `d` ensures the downstream main REopt run uses the + same sizes the MPC SOC trajectory was computed against. + +Returns the tuple `(pv_kw, bess_kw, bess_kwh)` for the caller to use when +building per-window MPC posts. +""" +function _mpc_resolve_sizes!(d::Dict) + pv = get!(d, "PV", Dict()) + bess = get!(d, "ElectricStorage", Dict()) + + function _is_fixed(dct, lo_key, hi_key) + lo = get(dct, lo_key, nothing) + hi = get(dct, hi_key, nothing) + return lo !== nothing && hi !== nothing && Float64(lo) == Float64(hi) + end + + # Guard: daily_foresight_optimized requires a non-zero ElectricStorage. + # If the user has pinned kW or kWh to zero (min == max == 0), fail fast + # with a clear message instead of running a pointless sizing/MPC pass. + if (_is_fixed(bess, "min_kw", "max_kw") && Float64(get(bess, "min_kw", 0)) == 0.0) || + (_is_fixed(bess, "min_kwh", "max_kwh") && Float64(get(bess, "min_kwh", 0)) == 0.0) + error("ElectricStorage power (kW) and energy (kWh) must both be greater than zero to run the daily_foresight_optimized dispatch option. Got min_kw=$(get(bess, "min_kw", nothing)), max_kw=$(get(bess, "max_kw", nothing)), min_kwh=$(get(bess, "min_kwh", nothing)), max_kwh=$(get(bess, "max_kwh", nothing)).") + end + + pv_fixed = _is_fixed(pv, "min_kw", "max_kw") + bess_fixed = _is_fixed(bess, "min_kw", "max_kw") && + _is_fixed(bess, "min_kwh", "max_kwh") + + if pv_fixed && bess_fixed + pv_kw = Float64(pv["min_kw"]) + bess_kw = Float64(bess["min_kw"]) + bess_kwh = Float64(bess["min_kwh"]) + @info "MPC: sizes already fixed via min_kw==max_kw. PV=$(pv_kw) kW, BESS=$(bess_kw) kW / $(bess_kwh) kWh." + return (pv_kw = pv_kw, bess_kw = bess_kw, bess_kwh = bess_kwh) + end + + @info "MPC: PV and/or ElectricStorage sizes not fixed — running REopt sizing first." + sizing_post = deepcopy(d) + # Strip any API-only sentinels before sizing. + if haskey(sizing_post, "ElectricStorage") + delete!(sizing_post["ElectricStorage"], "dispatch_options") + delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") + end + settings = get!(sizing_post, "Settings", Dict()) + settings["run_bau"] = false + settings["solver_name"] = get(settings, "solver_name", "HiGHS") + settings["timeout_seconds"] = get(settings, "timeout_seconds", 420) + settings["optimality_tolerance"] = get(settings, "optimality_tolerance", 0.001) + + solver_attributes = SolverAttributes( + settings["timeout_seconds"], settings["optimality_tolerance"]) + m = get_solver_model(get_solver_model_type(settings["solver_name"]), solver_attributes) + + model_inputs = reoptjl.REoptInputs(sizing_post) + sizing_results = reoptjl.run_reopt(m, model_inputs) + + if get(sizing_results, "status", "") != "optimal" + error("MPC sizing pre-step did not solve to optimality (status = $(get(sizing_results, "status", "unknown"))).") + end + + pv_kw = Float64(get(get(sizing_results, "PV", Dict()), "size_kw", 0.0)) + bess_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) + bess_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) + + # Lock the resolved sizes in `d` so the downstream main REopt run uses + # the SAME sizes that the MPC SOC trajectory is consistent with. + pv["min_kw"] = pv_kw + pv["max_kw"] = pv_kw + bess["min_kw"] = bess_kw + bess["max_kw"] = bess_kw + bess["min_kwh"] = bess_kwh + bess["max_kwh"] = bess_kwh + + @info "MPC: sized PV=$(pv_kw) kW, BESS=$(bess_kw) kW / $(bess_kwh) kWh (locked into d via min==max)." + return (pv_kw = pv_kw, bess_kw = bess_kw, bess_kwh = bess_kwh) +end + +# Core MPC computation. Takes the already-parsed (and api-key-stripped) request +# dict `d` and returns the results dict (dispatch series, costs, etc.). +# May throw; callers handle error responses. +# Called by both /mpc (standalone) and /reopt (when dispatch_options = +# "daily_foresight_optimized"). +function get_mpc_results(d::Dict)::Dict + # ---- Shared settings (with sensible defaults) ---- + settings = get(d, "Settings", Dict()) + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && xpress_installed != "True" + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. " * + "Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." + end + + optimality_tolerance = Float64(get(settings, "optimality_tolerance", 0.001)) + + # ---- Hard-coded MPC config ---- + # Full-year, 24-h horizon, year-end wrap-around, 60 s/iter solver cap. + time_steps_per_hour = Int(get(settings, "time_steps_per_hour", 1)) + length_of_data = 8760 * time_steps_per_hour + horizon = 24 * time_steps_per_hour + per_iter_timeout_s = 60.0 + + # ---- Guarantee presence of the tech / load sub-dicts ---- + # Done up front so PV/load resolution and sizing can mutate them in + # place, and downstream code can use plain indexing. + pv = get!(d, "PV", Dict()) + bess = get!(d, "ElectricStorage", Dict()) + eload = get!(d, "ElectricLoad", Dict()) + et = get(d, "ElectricTariff", Dict()) + eutil = get(d, "ElectricUtility", Dict()) + + # ---- Resolve PV production factor series ---- + # If not user-provided, derive via REopt.jl PVWatts and cache into `d` + # so both the sizing pre-step and downstream main REopt run reuse it + # without a second PVWatts call. + pv_prod_factor = if haskey(pv, "production_factor_series") && !isempty(pv["production_factor_series"]) + # Broadcast-convert: handles Vector{Any} from JSON parsing + # (convert(Vector{Float64}, ::Vector{Any}) would throw). + Float64.(pv["production_factor_series"]) + else + series = _mpc_generate_pv_production_factor(d, time_steps_per_hour) + pv["production_factor_series"] = series + series + end + + # ---- Resolve electric load series ---- + # Mirror the PV pattern: if user-provided, use it; else derive via + # REopt.jl ElectricLoad (DOE CRB path) and cache into `d`. + load_series = if haskey(eload, "loads_kw") && !isempty(eload["loads_kw"]) + Float64.(eload["loads_kw"]) + else + series = _mpc_generate_loads_kw(d, time_steps_per_hour) + eload["loads_kw"] = series + series + end + + # ---- Length checks (8760 or 8784 * tsh) ---- + # No mutation of user-supplied series. A leap-year-length input + # (8784*tsh) is accepted as-is; `_mpc_slice_with_wrap` reads the extra + # day from it for year-end look-ahead instead of wrapping to Jan 1. + pv_prod_factor = _mpc_check_series_length("PV.production_factor_series", + pv_prod_factor, time_steps_per_hour, length_of_data) + load_series = _mpc_check_series_length("ElectricLoad.loads_kw", + load_series, time_steps_per_hour, length_of_data) + + # ---- Resolve PV and ElectricStorage sizes ---- + # Runs AFTER PV/load resolution so the (possibly internal) sizing + # REopt run reuses the cached series instead of re-deriving them. + # Either pulls sizes from min_kw==max_kw / min_kwh==max_kwh, or runs + # a sizing REopt and locks the resolved values into `d` so the + # downstream main REopt run uses the SAME sizes. + sizes = _mpc_resolve_sizes!(d) + pv_kw = sizes.pv_kw + bess_kw = sizes.bess_kw + bess_kwh = sizes.bess_kwh + + soc_init = Float64(get(bess, "soc_init_fraction", 0.5)) + soc_min = Float64(get(bess, "soc_min_fraction", 0.2)) + # REopt ElectricStorage exposes three component efficiencies; MPC's + # MPCElectricStorage uses combined charge/discharge round-trip halves. + # Match REopt's own composition: charge = rectifier * sqrt(internal), + # discharge = inverter * sqrt(internal). Defaults from REopt.jl. + rect_eff = Float64(get(bess, "rectifier_efficiency_fraction", 0.96)) + inv_eff = Float64(get(bess, "inverter_efficiency_fraction", 0.96)) + int_eff = Float64(get(bess, "internal_efficiency_fraction", 0.975)) + charge_eff = rect_eff * sqrt(int_eff) + discharge_eff = inv_eff * sqrt(int_eff) + + # Year for URDB schedule decoding is sourced from ElectricLoad.year + # (the canonical year input in REopt; ElectricTariff's `year` kwarg is + # an internal pass-through, not a user input). + tariff_year = haskey(eload, "year") ? Int(eload["year"]) : nothing + tariff = _mpc_build_tariff_arrays(et, tariff_year, time_steps_per_hour, length_of_data) + energy_rates = tariff.energy_rates + tou_demand_rates = tariff.tou_demand_rates # one per ratchet, $/kW + tou_demand_ts_all = tariff.tou_demand_time_steps # full-year indices per ratchet + monthly_demand_rates = tariff.monthly_demand_rates # length 12, $/kW per month + + # Optional emissions series — broadcast zero if not provided. + emissions = haskey(eutil, "emissions_factor_series_lb_CO2_per_kwh") ? + Float64.(eutil["emissions_factor_series_lb_CO2_per_kwh"]) : + zeros(Float64, length_of_data) + emissions = _mpc_check_series_length("ElectricUtility.emissions_factor_series_lb_CO2_per_kwh", + emissions, time_steps_per_hour, length_of_data) + + # ---- Demand tracking state ---- + # Reverse indices into the calendar: each timestep maps to its + # 1-based month (always defined) and 1-based TOU ratchet (0 if no + # ratchet applies). Both are vector lookups (O(1)) used inside the + # rolling-horizon loop to build window-local time-step groupings + # and to attribute realized peaks to the right month/tier. + month_starts = get_month_transition_timesteps(time_steps_per_hour) + ts_to_month = Vector{Int}(undef, length_of_data) + for m in 1:12 + s = month_starts[m] + e = m < 12 ? month_starts[m+1] - 1 : length_of_data + ts_to_month[s:e] .= m + end + + ts_to_tier = zeros(Int, length_of_data) + for (t, ratchet_ts) in enumerate(tou_demand_ts_all), g in ratchet_ts + if 1 <= g <= length_of_data + ts_to_tier[g] = t + end + end + + n_tou = length(tou_demand_rates) + # `monthly_previous_peak_demands` and `tou_previous_peak_demands` are + # passed into every MPC post AND mutated after each solve so that + # subsequent windows "see" the realized peaks so far. Both reset at + # month boundaries (matches REopt billing semantics; multi-month + # ratchet/lookback support would require a different reset cadence). + tou_previous_peak_demands = zeros(Float64, n_tou) + monthly_previous_peak_demands = zeros(Float64, 12) + monthly_demand_cost_total = 0.0 + tou_demand_cost_total = 0.0 + tou_peaks_by_month = Vector{Vector{Float64}}() + monthly_peaks = Float64[] + + # ---- Dispatch accumulators (1 value per executed timestep) ---- + dispatch = Dict( + "PV" => Dict( + "electric_to_load_series_kw" => Float64[], + "electric_to_storage_series_kw" => Float64[], + "electric_to_grid_series_kw" => Float64[], + "electric_curtailed_series_kw" => Float64[], + ), + "ElectricStorage" => Dict( + "storage_to_load_series_kw" => Float64[], + "soc_series_fraction" => Float64[], + ), + "ElectricUtility" => Dict( + "electric_to_load_series_kw" => Float64[], + "electric_to_storage_series_kw" => Float64[], + "emissions_series_lb_CO2" => Float64[], + ), + "ElectricLoad" => Dict( + "load_series_kw" => Float64[], + ), + ) + cost_series = Float64[] + total_energy_cost = 0.0 + bess_soc_init_fraction = soc_init + + # ---- Build the (reused) MPC post template ---- + # Note: no Settings block here; the solver is constructed below per + # window and passed directly to run_mpc(model, post). All four demand + # inputs (TOU + monthly rates and previous-peak vectors) are passed + # so the optimizer's objective accounts for *both* charges; omitting + # `monthly_*` would silently optimize without the monthly demand + # contribution and over-state savings under monthly-demand tariffs. + function build_mpc_post(window_pv, window_load, window_rates, window_emissions, + window_tou_ts, window_monthly_ts, + tou_prev_peaks, monthly_prev_peaks, soc_init_frac) + return Dict( + "PV" => Dict( + "size_kw" => pv_kw, + "production_factor_series" => window_pv, + ), + "ElectricStorage" => Dict( + "size_kw" => bess_kw, + "size_kwh" => bess_kwh, + "charge_efficiency" => charge_eff, + "discharge_efficiency" => discharge_eff, + "soc_init_fraction" => soc_init_frac, + "soc_min_fraction" => soc_min, + ), + "ElectricLoad" => Dict( + "loads_kw" => window_load, + ), + "ElectricTariff" => Dict( + "energy_rates" => window_rates, + "tou_demand_rates" => tou_demand_rates, + "tou_demand_time_steps" => window_tou_ts, + "tou_previous_peak_demands" => tou_prev_peaks, + "monthly_demand_rates" => monthly_demand_rates, + "time_steps_monthly" => window_monthly_ts, + "monthly_previous_peak_demands" => monthly_prev_peaks, + ), + "ElectricUtility" => Dict( + "emissions_factor_series_lb_CO2_per_kwh" => window_emissions, + ), + ) + end + + @info "MPC: starting rolling-horizon loop ($(length_of_data) iterations, horizon=$(horizon))" + for idx in 1:length_of_data + end_ts = idx + horizon - 1 + window_len = end_ts - idx + 1 + + window_pv = _mpc_slice_with_wrap(pv_prod_factor, idx, end_ts) + window_load = _mpc_slice_with_wrap(load_series, idx, end_ts) + window_rates = _mpc_slice_with_wrap(energy_rates, idx, end_ts) + window_emiss = _mpc_slice_with_wrap(emissions, idx, end_ts) + + # Window-local time-step groupings, both built from a single pass + # over the horizon. TOU buckets are 1 vector per ratchet (length + # n_tou); monthly buckets are 1 vector per month (length 12). + # Global indices wrap on `length_of_data` (8760·tsh) so the month + # / tier of a wrapped step matches its calendar position. + window_tou_ts = [Int[] for _ in 1:n_tou] + window_monthly_ts = [Int[] for _ in 1:12] + for k in 1:window_len + g = idx + k - 1 + if g > length_of_data + g -= length_of_data + end + push!(window_monthly_ts[ts_to_month[g]], k) + tier = ts_to_tier[g] + if tier > 0 + push!(window_tou_ts[tier], k) + end + end + + post = build_mpc_post(window_pv, window_load, window_rates, window_emiss, + window_tou_ts, window_monthly_ts, + tou_previous_peak_demands, monthly_previous_peak_demands, + bess_soc_init_fraction) + + model = get_solver_model(get_solver_model_type(solver_name), + SolverAttributes(per_iter_timeout_s, optimality_tolerance)) + result = reoptjl.run_mpc(model, post) + + # ---- Extract first-timestep dispatch (perfect-forecast) ---- + pv_res = result["PV"] + bess_res = result["ElectricStorage"] + util_res = result["ElectricUtility"] + + pv_to_load = pv_res["electric_to_load_series_kw"][1] + pv_to_bess = pv_res["electric_to_storage_series_kw"][1] + pv_to_grid = haskey(pv_res, "electric_to_grid_series_kw") ? pv_res["electric_to_grid_series_kw"][1] : 0.0 + pv_curtailed = haskey(pv_res, "electric_curtailed_series_kw") ? pv_res["electric_curtailed_series_kw"][1] : 0.0 + bess_to_load = bess_res["storage_to_load_series_kw"][1] + bess_soc = bess_res["soc_series_fraction"][1] + util_to_load = util_res["electric_to_load_series_kw"][1] + util_to_bess = util_res["electric_to_storage_series_kw"][1] + grid_power = max(util_to_load + util_to_bess, 0.0) + + push!(dispatch["PV"]["electric_to_load_series_kw"], pv_to_load) + push!(dispatch["PV"]["electric_to_storage_series_kw"], pv_to_bess) + push!(dispatch["PV"]["electric_to_grid_series_kw"], pv_to_grid) + push!(dispatch["PV"]["electric_curtailed_series_kw"], pv_curtailed) + push!(dispatch["ElectricStorage"]["storage_to_load_series_kw"], bess_to_load) + push!(dispatch["ElectricStorage"]["soc_series_fraction"], round(bess_soc, digits=6)) + push!(dispatch["ElectricUtility"]["electric_to_load_series_kw"], util_to_load) + push!(dispatch["ElectricUtility"]["electric_to_storage_series_kw"], util_to_bess) + push!(dispatch["ElectricUtility"]["emissions_series_lb_CO2"], + emissions[idx] * grid_power / time_steps_per_hour) + push!(dispatch["ElectricLoad"]["load_series_kw"], load_series[idx]) + + # ---- Energy cost ---- + step_energy_cost = grid_power * energy_rates[idx] / time_steps_per_hour + push!(cost_series, step_energy_cost) + total_energy_cost += step_energy_cost + + # ---- Carry state ---- + bess_soc_init_fraction = bess_soc + + # ---- Demand peak tracking (per-tier and per-month) ---- + # Attribute realized peak grid power to its TOU ratchet (if any) + # and to its calendar month. Identity comes from the reverse + # indices, NOT from rate values — so coincident-rate ratchets + # and equal-rate months remain separate billing pools. + m_now = ts_to_month[idx] + monthly_previous_peak_demands[m_now] = + max(grid_power, monthly_previous_peak_demands[m_now]) + + if n_tou > 0 + tier_now = ts_to_tier[idx] + if tier_now > 0 + tou_previous_peak_demands[tier_now] = + max(grid_power, tou_previous_peak_demands[tier_now]) + end + end + + # ---- Month transition: close out the just-ended month ---- + # Both monthly and TOU peaks reset at month boundaries (matches + # REopt billing semantics). The last timestep of month m is + # detected by m_now != ts_to_month[idx+1]; we handle Dec via + # idx == length_of_data. + is_month_end = (idx == length_of_data) || (ts_to_month[idx + 1] != m_now) + if is_month_end + push!(monthly_peaks, monthly_previous_peak_demands[m_now]) + monthly_demand_cost_total += + monthly_previous_peak_demands[m_now] * monthly_demand_rates[m_now] + monthly_previous_peak_demands[m_now] = 0.0 + + if n_tou > 0 + push!(tou_peaks_by_month, copy(tou_previous_peak_demands)) + tou_demand_cost_total += sum(tou_previous_peak_demands .* tou_demand_rates) + fill!(tou_previous_peak_demands, 0.0) + end + end + end + + @info "MPC: loop complete. total_energy_cost=\$$(round(total_energy_cost, digits=2))" + + return Dict( + "MPC" => Dict( + "time_steps_per_hour" => time_steps_per_hour, + "horizon" => horizon, + ), + "PV" => Dict("size_kw" => pv_kw), + "ElectricStorage" => Dict("size_kw" => bess_kw, "size_kwh" => bess_kwh), + "dispatch" => dispatch, + "ElectricTariff" => Dict( + "total_energy_cost" => total_energy_cost, + "energy_cost_series_per_timestep" => cost_series, + "total_tou_demand_cost" => tou_demand_cost_total, + "total_monthly_demand_cost" => monthly_demand_cost_total, + "tou_peaks_by_month_kw" => tou_peaks_by_month, + "monthly_peaks_kw" => monthly_peaks, + ), + "status" => "optimal", + "reopt_version" => string(pkgversion(reoptjl)), + ) +end diff --git a/reoptjl/migrations/0117_merge_20260530_1640.py b/reoptjl/migrations/0117_merge_20260530_1640.py new file mode 100644 index 000000000..0302ff8e6 --- /dev/null +++ b/reoptjl/migrations/0117_merge_20260530_1640.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.26 on 2026-05-30 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0116_alter_apimeta_api_key_alter_apimeta_job_type'), + ('reoptjl', '0116_rename_state_of_health_electricstorageoutputs_state_of_health_series_fraction_and_more'), + ] + + operations = [ + ] From bfcef2a4c9faf5ae6ea8a2d10f8e0c65cdf1d8e2 Mon Sep 17 00:00:00 2001 From: lixiangk1 <72464565+lixiangk1@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:45:04 -0600 Subject: [PATCH 02/28] Update MPC modeling --- julia_src/http.jl | 28 +- julia_src/mpc.jl | 688 ++++++++++++++++++---------------------------- 2 files changed, 294 insertions(+), 422 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index e6f94a42c..b03a742f8 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -66,14 +66,23 @@ function reopt(req::HTTP.Request) delete!(d, "api_key") end + settings = d["Settings"] + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && !(xpress_installed=="True") + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time + Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" + end + # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_options == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. + # Runs after the Xpress availability check so the resolved solver_name is passed in. electric_storage = get(d, "ElectricStorage", Dict()) if get(electric_storage, "dispatch_options", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." - mpc_results = get_mpc_results(d) + mpc_results = get_mpc_results(d; solver_name=solver_name) soc = mpc_results["dispatch"]["ElectricStorage"]["soc_series_fraction"] d["ElectricStorage"]["fixed_soc_series_fraction"] = soc d["ElectricStorage"]["dispatch_options"] = "custom" @@ -86,13 +95,7 @@ function reopt(req::HTTP.Request) end end - settings = d["Settings"] - solver_name = get(settings, "solver_name", "HiGHS") - if solver_name == "Xpress" && !(xpress_installed=="True") - solver_name = "HiGHS" - @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time - Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" - end + #TODO: What timeout and optimality tolerance should MPC use? timeout_seconds = pop!(settings, "timeout_seconds") optimality_tolerance = pop!(settings, "optimality_tolerance") solver_attributes = SolverAttributes(timeout_seconds, optimality_tolerance) @@ -844,7 +847,14 @@ function mpc(req::HTTP.Request) ENV["NREL_DEVELOPER_API_KEY"] = test_nrel_developer_api_key delete!(d, "api_key") end - results = get_mpc_results(d) + settings = get(d, "Settings", Dict()) + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && !(xpress_installed=="True") + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. + Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." + end + results = get_mpc_results(d; solver_name=solver_name) catch e @error "MPC failed" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 21cb9510a..af376cdbe 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -3,22 +3,19 @@ # MPC (Model Predictive Control) endpoint # ---------------------------------------------------------------------------- # Rolling-horizon dispatch with one day look-ahead using REopt.run_mpc. Assumes: -# * PV and ElectricStorage are the only available technologies (perfect forecast) +# * PV and ElectricStorage are the only available technologies # * Only perfect forecast scenarios are modeled (no forecast errors) -# * Sizing: Perform a sizing run first if PV or ElectricStorage sizes are not provided (storage sizes must be greater than zero) +# * Sizing: Perform a sizing run first if PV or ElectricStorage sizes are not provided (storage sizes must be > 0) # * PV: Use PVWatts if `PV.production_factor_series` is not provided # * ElectricLoad: Use commercial reference profiles if `ElectricLoad.loads_kw` is not provided -# * Code currently wraps with Jan 1 data to determine Dec 31 dispatch (allows leap-year data, which avoids the need to wrap) +# * Code wraps with Jan 1 data to determine Dec 31 dispatch (leap-year inputs are not supported) # * MPC settings are currently hard coded (e.g., forecast horizon, control horizon, optimization horizon) # ============================================================================ """ get_month_transition_timesteps(time_steps_per_hour) -Return the 1-based timestep index marking the START of each month for a -non-leap year beginning Jan 1 00:00. Length 12; first element == 1. -The last "transition" (end of December) is implicitly `length_of_data + 1` -and is handled by the caller. +Return an array of length 12 specifying the index marking the start of each month in a non-leap year """ function get_month_transition_timesteps(time_steps_per_hour::Int) # hours in each month for a non-leap year @@ -32,15 +29,11 @@ function get_month_transition_timesteps(time_steps_per_hour::Int) end """ - _mpc_slice_with_wrap(arr, idx, end_idx) + slice_data(arr, idx, end_idx) -Return `arr[idx:end_idx]` but wrap around to the start of `arr` if -`end_idx > length(arr)`. The wrap threshold is the actual array length, -not `length_of_data`, so a leap-year-length input (8784*tsh) naturally -supplies the extra day for the year-end look-ahead window before any -wrap is needed. +Returns arr[idx:end_idx] but wrap around to the start of arr if end_idx > length(arr). """ -function _mpc_slice_with_wrap(arr::AbstractVector, idx::Int, end_idx::Int) +function slice_data(arr::AbstractVector, idx::Int, end_idx::Int) n = length(arr) if end_idx <= n return arr[idx:end_idx] @@ -51,383 +44,286 @@ function _mpc_slice_with_wrap(arr::AbstractVector, idx::Int, end_idx::Int) end """ - _mpc_check_series_length(name, series, time_steps_per_hour, length_of_data) - -Validate that a user-supplied annual time series has either -`8760 * time_steps_per_hour` entries (standard year) or -`8784 * time_steps_per_hour` entries (leap year). Returns the series -unchanged. - -The MPC loop iterates over `length_of_data = 8760 * time_steps_per_hour` -timesteps; if a leap-year-length series is supplied, the extra day is -used as additional look-ahead data by `_mpc_slice_with_wrap` (which wraps -on the actual array length, not on `length_of_data`). Any other length is -a hard error — REopt.jl's own `check_and_adjust_load_length` would -silently repeat/pad, masking the mistake. + check_series_length(name, series, time_steps_per_hour, length_of_data) + +Validate that a user-entered time series is of length 8760 * time_steps_per_hour (cannot be a leap year). """ -function _mpc_check_series_length(name::String, series::AbstractVector, - time_steps_per_hour::Int, length_of_data::Int) +function check_series_length(name::String, series::AbstractVector, length_of_data::Int) n = length(series) - n == length_of_data && return series - if n == 8784 * time_steps_per_hour - @info "MPC: $name has leap-year length ($n); the extra day will be used " * - "for year-end look-ahead instead of wrapping to Jan 1." + if n == length_of_data return series end - # Diagnostic: if `n` is consistent with 8760 hours at a *different* - # sub-hourly resolution, the user probably mis-declared - # `Settings.time_steps_per_hour`. - tsh_hint = "" - for tsh_guess in (1, 2, 4) - tsh_guess == time_steps_per_hour && continue - if n == 8760 * tsh_guess || n == 8784 * tsh_guess - tsh_hint = " (length is consistent with time_steps_per_hour=$(tsh_guess); " * - "check Settings.time_steps_per_hour)." - break - end - end - error("MPC: $name length $n != " * - "8760 * time_steps_per_hour ($length_of_data) " * - "and != 8784 * time_steps_per_hour ($(8784 * time_steps_per_hour))." * - tsh_hint) + error("MPC: $name length $n != 8760 * time_steps_per_hour ($length_of_data).") end """ - _mpc_build_tariff_arrays(et_input, year, time_steps_per_hour, length_of_data) - -Construct a `REopt.ElectricTariff` from the user's ElectricTariff input dict -(which uses the real REopt input schema: `urdb_label`, `urdb_response`, -`urdb_utility_name`+`urdb_rate_name`, `tou_energy_rates_per_kwh`, -`monthly_energy_rates`, `monthly_demand_rates`, `blended_annual_energy_rate`, -`blended_annual_demand_rate`, etc.) and extract the processed arrays MPC -needs for its per-window posts. - -Returns a NamedTuple with: - * `energy_rates::Vector{Float64}` (length `length_of_data`) - * `monthly_demand_rates::Vector{Float64}` (length 12, all zeros if not set) - * `tou_demand_rates::Vector{Float64}` (one per ratchet; empty for non-URDB) - * `tou_demand_time_steps::Vector{Vector{Int}}` (full-year indices per ratchet) - -Multi-tier rates are flattened to a single tier (MPC does not model tiers). + get_tariff_inputs(electric_tariff, year, time_steps_per_hour) + +Call REopt.ElectricTariff to get energy_rates, monthly_demand_rates, tou_demand_rates, tou_demand_ratchet_time_steps +MPC does not currently model tiers (tiers are flattened), coincident peak charges, or demand lookback. """ -function _mpc_build_tariff_arrays(et_input::Dict, year::Union{Int,Nothing}, - time_steps_per_hour::Int, length_of_data::Int) - # Only forward known ElectricTariff kwargs so unrelated keys (e.g. a - # parsed API artifact) don't trip the constructor. - known_kwargs = (:urdb_label, :urdb_response, :urdb_utility_name, +function get_tariff_inputs(electric_tariff::Dict, year::Union{Int,Nothing}, time_steps_per_hour::Int) + + # TODO: Are all of these relevant? Any missing inputs? + # TODO: Implement lookback? (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now + # TODO: Need to think through NEM or passing back export values (wholesale_rate, export_rate_beyond_net_metering_limit) + tariff_kwargs = (:urdb_label, :urdb_response, :urdb_utility_name, :urdb_rate_name, :urdb_metadata, :wholesale_rate, :export_rate_beyond_net_metering_limit, :monthly_energy_rates, :monthly_demand_rates, :blended_annual_energy_rate, :blended_annual_demand_rate, :add_monthly_rates_to_urdb_rate, :tou_energy_rates_per_kwh, :add_tou_energy_rates_to_urdb_rate, - :remove_tiers, :demand_lookback_months, - :demand_lookback_percent, :demand_lookback_range, - :coincident_peak_load_active_time_steps, - :coincident_peak_load_charge_per_kw) - kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in et_input - if Symbol(k) in known_kwargs) - # Force single-tier arrays so MPC sees a flat schedule; without this the - # constructor could return `length_of_data x n_tiers` and we'd silently - # only use one tier (and inconsistently across energy vs demand). - kwargs[:remove_tiers] = get(kwargs, :remove_tiers, true) - - tariff = reoptjl.ElectricTariff(; year = year, - time_steps_per_hour = time_steps_per_hour, - kwargs...) - - # All arrays are single-tier (we forced `remove_tiers=true`). - energy_rates = vec(Float64.(tariff.energy_rates[:, 1])) - length(energy_rates) == length_of_data || error( - "MPC: derived energy_rates length $(length(energy_rates)) != length_of_data $(length_of_data).") + :demand_lookback_months, :demand_lookback_percent, :demand_lookback_range) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in electric_tariff if Symbol(k) in tariff_kwargs) - monthly_demand_rates = isempty(tariff.monthly_demand_rates) ? - zeros(Float64, 12) : vec(Float64.(tariff.monthly_demand_rates[:, 1])) - length(monthly_demand_rates) == 12 || error( - "MPC: derived monthly_demand_rates length $(length(monthly_demand_rates)) != 12.") + # MPC does not currently model tiered rates + kwargs[:remove_tiers] = true + tariff = reoptjl.ElectricTariff(; year = year, time_steps_per_hour = time_steps_per_hour, kwargs...) - tou_demand_rates = isempty(tariff.tou_demand_rates) ? - Float64[] : vec(Float64.(tariff.tou_demand_rates[:, 1])) - tou_demand_time_steps = [Int.(v) for v in tariff.tou_demand_ratchet_time_steps] + energy_rates = Float64.(tariff.energy_rates[:, 1]) + monthly_demand_rates = isempty(tariff.monthly_demand_rates) ? + zeros(Float64, 12) : Float64.(tariff.monthly_demand_rates[:, 1]) + tou_demand_rates = isempty(tariff.tou_demand_rates) ? Float64[] : Float64.(tariff.tou_demand_rates[:, 1]) + tou_demand_ratchet_time_steps = [Int.(v) for v in tariff.tou_demand_ratchet_time_steps] + # TODO: Track lookback variables - demand_lookback_months, demand_lookback_percent, demand_lookback_range? return (energy_rates = energy_rates, monthly_demand_rates = monthly_demand_rates, tou_demand_rates = tou_demand_rates, - tou_demand_time_steps = tou_demand_time_steps) + tou_demand_ratchet_time_steps = tou_demand_ratchet_time_steps) end """ - _mpc_generate_pv_production_factor(d, time_steps_per_hour) + generate_pv_production_factors(d, time_steps_per_hour) -Derive a PV production factor series via `REopt.get_production_factor` -(PVWatts) using `Site.latitude`/`Site.longitude` and the PV inputs in `d`. -Returns a `Vector{Float64}`. Throws if lat/lon are missing. +Generate a PV production factor series using PVWatts by calling REopt.get_production_factor """ -function _mpc_generate_pv_production_factor(d::Dict, time_steps_per_hour::Int) +function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) - haskey(site, "latitude") || error( - "MPC: Site.latitude is required to derive PV.production_factor_series via PVWatts.") - haskey(site, "longitude") || error( - "MPC: Site.longitude is required to derive PV.production_factor_series via PVWatts.") + if !haskey(site, "latitude") + error("MPC: Site.latitude is required to generate PV.production_factor_series using PVWatts.") + end + if !haskey(site, "longitude") + error("MPC: Site.longitude is required to generate PV.production_factor_series using PVWatts.") + end lat = Float64(site["latitude"]) lon = Float64(site["longitude"]) - @info "MPC: PV.production_factor_series not provided; deriving via REopt.jl PVWatts (lat=$(lat), lon=$(lon))." + @info "MPC: PV.production_factor_series not provided; generating using PVWatts through REopt.jl (lat=$(lat), lon=$(lon))." - # Only forward known REopt.PV kwargs that affect the production factor. pv = get(d, "PV", Dict()) pv_pf_kwargs = (:array_type, :tilt, :module_type, :losses, :azimuth, :gcr, :radius, :name, :location, :dc_ac_ratio, :inv_eff) - kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in pv - if Symbol(k) in pv_pf_kwargs) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in pv if Symbol(k) in pv_pf_kwargs) pv_struct = reoptjl.PV(; latitude = lat, kwargs...) - derived = reoptjl.get_production_factor(pv_struct, lat, lon; - time_steps_per_hour = time_steps_per_hour) - return collect(Float64.(derived)) + pv_production_factor_series = reoptjl.get_production_factor(pv_struct, lat, lon; + time_steps_per_hour = time_steps_per_hour) + return Vector{Float64}(pv_production_factor_series) end """ - _mpc_generate_loads_kw(d, time_steps_per_hour) - -Derive an electric load profile via `REopt.ElectricLoad` from the user's -standard ElectricLoad inputs (`doe_reference_name`+`city` / -`blended_doe_reference_names`+`blended_doe_reference_percents`, -`annual_kwh`, `monthly_totals_kwh`, `monthly_peaks_kw`, `year`, etc.) — -the same DOE CRB derivation path the main REopt run would take. Returns a -`Vector{Float64}`. Requires `Site.latitude`/`Site.longitude`. + generate_loads_kw(d, time_steps_per_hour) + +Generate an electric load profile using DOE Commercial Reference buildings by calling REopt.ElectricLoad """ -function _mpc_generate_loads_kw(d::Dict, time_steps_per_hour::Int) +function generate_loads_kw(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) - haskey(site, "latitude") || error( - "MPC: Site.latitude is required to derive ElectricLoad.loads_kw from a DOE CRB profile.") - haskey(site, "longitude") || error( - "MPC: Site.longitude is required to derive ElectricLoad.loads_kw from a DOE CRB profile.") + if !haskey(site, "latitude") + error("MPC: Site.latitude is required to generate ElectricLoad.loads_kw using a DOE CRB profile.") + end + if !haskey(site, "longitude") + error("MPC: Site.longitude is required to generate ElectricLoad.loads_kw using a DOE CRB profile.") + end lat = Float64(site["latitude"]) lon = Float64(site["longitude"]) - @info "MPC: ElectricLoad.loads_kw not provided; deriving via REopt.jl ElectricLoad (lat=$(lat), lon=$(lon))." + @info "MPC: ElectricLoad.loads_kw not provided; generating via REopt.jl ElectricLoad (lat=$(lat), lon=$(lon))." - # Only forward known ElectricLoad kwargs. - eload = get(d, "ElectricLoad", Dict()) - known_kwargs = (:normalize_and_scale_load_profile_input, + electric_load = get(d, "ElectricLoad", Dict()) + + # TODO: Double check this list for relevance/missing inputs + load_kwargs = (:normalize_and_scale_load_profile_input, :path_to_csv, :doe_reference_name, :blended_doe_reference_names, :blended_doe_reference_percents, :year, :city, :annual_kwh, :monthly_totals_kwh, - :monthly_peaks_kw, :critical_loads_kw, :loads_kw_is_net, - :critical_loads_kw_is_net, :critical_load_fraction, - :operating_reserve_required_fraction, - :min_load_met_annual_fraction, :off_grid_flag) - kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in eload - if Symbol(k) in known_kwargs) - load_struct = reoptjl.ElectricLoad(; latitude = lat, longitude = lon, - time_steps_per_hour = time_steps_per_hour, - kwargs...) - return collect(Float64.(load_struct.loads_kw)) + :monthly_peaks_kw, :loads_kw_is_net) + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in electric_load if Symbol(k) in load_kwargs) + load = reoptjl.ElectricLoad(; latitude = lat, longitude = lon, + time_steps_per_hour = time_steps_per_hour, kwargs...) + return Vector{Float64}(load.loads_kw) end """ - _mpc_resolve_sizes!(d) - -Determines PV/ElectricStorage sizes for the MPC loop, using the actual -REopt input schema (`min_kw`/`max_kw`, `min_kwh`/`max_kwh`). - -Rules: - * If `min_kw == max_kw` for PV AND `min_kw == max_kw` AND - `min_kwh == max_kwh` for ElectricStorage → sizes are already fixed; use - those values directly. No sizing run. - * Otherwise → run a regular REopt sizing optimization, then mutate `d` to - set `min_kw = max_kw = sized_kw` (and similarly for kWh on storage). - Locking sizes in `d` ensures the downstream main REopt run uses the - same sizes the MPC SOC trajectory was computed against. - -Returns the tuple `(pv_kw, bess_kw, bess_kwh)` for the caller to use when -building per-window MPC posts. + get_technology_sizes!(d) + +Determine PV and ElectricStorage sizes for the MPC loop. If min_kw != max_kw and/or min_kwh != max_kwh, +call REopt to size technologies. User input battery sizes must also be greater than zero. """ -function _mpc_resolve_sizes!(d::Dict) +function get_technology_sizes!(d::Dict) pv = get!(d, "PV", Dict()) - bess = get!(d, "ElectricStorage", Dict()) + batt = get!(d, "ElectricStorage", Dict()) - function _is_fixed(dct, lo_key, hi_key) + function is_fixed(dct, lo_key, hi_key) lo = get(dct, lo_key, nothing) hi = get(dct, hi_key, nothing) return lo !== nothing && hi !== nothing && Float64(lo) == Float64(hi) end - # Guard: daily_foresight_optimized requires a non-zero ElectricStorage. - # If the user has pinned kW or kWh to zero (min == max == 0), fail fast - # with a clear message instead of running a pointless sizing/MPC pass. - if (_is_fixed(bess, "min_kw", "max_kw") && Float64(get(bess, "min_kw", 0)) == 0.0) || - (_is_fixed(bess, "min_kwh", "max_kwh") && Float64(get(bess, "min_kwh", 0)) == 0.0) - error("ElectricStorage power (kW) and energy (kWh) must both be greater than zero to run the daily_foresight_optimized dispatch option. Got min_kw=$(get(bess, "min_kw", nothing)), max_kw=$(get(bess, "max_kw", nothing)), min_kwh=$(get(bess, "min_kwh", nothing)), max_kwh=$(get(bess, "max_kwh", nothing)).") + # Check storage sizes are greater than zero + if Float64(get(batt, "max_kw", 1.0)) <= 0.0 || Float64(get(batt, "max_kwh", 1.0)) <= 0.0 + error("ElectricStorage max_kw and max_kwh must both be greater than zero " * + "to run the daily_foresight_optimized dispatch option.") end - pv_fixed = _is_fixed(pv, "min_kw", "max_kw") - bess_fixed = _is_fixed(bess, "min_kw", "max_kw") && - _is_fixed(bess, "min_kwh", "max_kwh") + pv_fixed = is_fixed(pv, "min_kw", "max_kw") + batt_fixed = is_fixed(batt, "min_kw", "max_kw") && + is_fixed(batt, "min_kwh", "max_kwh") - if pv_fixed && bess_fixed + if pv_fixed && batt_fixed pv_kw = Float64(pv["min_kw"]) - bess_kw = Float64(bess["min_kw"]) - bess_kwh = Float64(bess["min_kwh"]) - @info "MPC: sizes already fixed via min_kw==max_kw. PV=$(pv_kw) kW, BESS=$(bess_kw) kW / $(bess_kwh) kWh." - return (pv_kw = pv_kw, bess_kw = bess_kw, bess_kwh = bess_kwh) + batt_kw = Float64(batt["min_kw"]) + batt_kwh = Float64(batt["min_kwh"]) + @info "MPC: fixed technology sizes entered, PV = $(pv_kw) kW, battery = $(batt_kw) kW / $(batt_kwh) kWh." + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh) end - @info "MPC: PV and/or ElectricStorage sizes not fixed — running REopt sizing first." + @info "MPC: PV and/or ElectricStorage sizes are not specified — running REopt sizing first." sizing_post = deepcopy(d) - # Strip any API-only sentinels before sizing. + + # Delete inputs specific to the heuristic battery dispatch run if haskey(sizing_post, "ElectricStorage") delete!(sizing_post["ElectricStorage"], "dispatch_options") delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") end + settings = get!(sizing_post, "Settings", Dict()) settings["run_bau"] = false + + # TODO: Okay to hard code defaults here? solver_name check is redundant if function is only called from get_mpc_results settings["solver_name"] = get(settings, "solver_name", "HiGHS") settings["timeout_seconds"] = get(settings, "timeout_seconds", 420) settings["optimality_tolerance"] = get(settings, "optimality_tolerance", 0.001) - solver_attributes = SolverAttributes( - settings["timeout_seconds"], settings["optimality_tolerance"]) + solver_attributes = SolverAttributes(settings["timeout_seconds"], settings["optimality_tolerance"]) m = get_solver_model(get_solver_model_type(settings["solver_name"]), solver_attributes) model_inputs = reoptjl.REoptInputs(sizing_post) sizing_results = reoptjl.run_reopt(m, model_inputs) if get(sizing_results, "status", "") != "optimal" - error("MPC sizing pre-step did not solve to optimality (status = $(get(sizing_results, "status", "unknown"))).") + error("MPC sizing pre-step did not solve (status = $(get(sizing_results, "status", "unknown"))).") end pv_kw = Float64(get(get(sizing_results, "PV", Dict()), "size_kw", 0.0)) - bess_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) - bess_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) + batt_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) + batt_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) + + if batt_kw <= 0.0 || batt_kwh <= 0.0 + error("MPC: Optimal REopt sizing does not include ElectricStorage (size_kw=$(batt_kw), size_kwh=$(batt_kwh)). " * + "The daily_foresight_optimized dispatch option requires a non-zero battery size. " * + "Set ElectricStorage.min_kw == ElectricStorage.max_kw and " * + "ElectricStorage.min_kwh == ElectricStorage.max_kwh to fix sizes manually.") + end - # Lock the resolved sizes in `d` so the downstream main REopt run uses - # the SAME sizes that the MPC SOC trajectory is consistent with. pv["min_kw"] = pv_kw pv["max_kw"] = pv_kw - bess["min_kw"] = bess_kw - bess["max_kw"] = bess_kw - bess["min_kwh"] = bess_kwh - bess["max_kwh"] = bess_kwh + batt["min_kw"] = batt_kw + batt["max_kw"] = batt_kw + batt["min_kwh"] = batt_kwh + batt["max_kwh"] = batt_kwh - @info "MPC: sized PV=$(pv_kw) kW, BESS=$(bess_kw) kW / $(bess_kwh) kWh (locked into d via min==max)." - return (pv_kw = pv_kw, bess_kw = bess_kw, bess_kwh = bess_kwh) + @info "MPC: REopt sizing solved with PV = $(pv_kw) kW and battery = $(batt_kw) kW / $(batt_kwh) kWh." + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh) end -# Core MPC computation. Takes the already-parsed (and api-key-stripped) request -# dict `d` and returns the results dict (dispatch series, costs, etc.). -# May throw; callers handle error responses. -# Called by both /mpc (standalone) and /reopt (when dispatch_options = -# "daily_foresight_optimized"). -function get_mpc_results(d::Dict)::Dict - # ---- Shared settings (with sensible defaults) ---- - settings = get(d, "Settings", Dict()) - solver_name = get(settings, "solver_name", "HiGHS") - if solver_name == "Xpress" && xpress_installed != "True" - solver_name = "HiGHS" - @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. " * - "Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." - end +function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict + """ + Run a full-year rolling-horizon MPC dispatch for PV + ElectricStorage by + calling `REopt.run_mpc` once per timestep with a 24-hour look-ahead. + + Inputs: + d::Dict, REopt inputs dictionary + solver_name::String, solver to use ("HiGHS", "Cbc", "SCIP", or "Xpress") + Returns a Dict with PV and BATT sizes, dispatch time series, and cost metrics + """ + + settings = get!(d, "Settings", Dict()) + settings["solver_name"] = solver_name + + # TODO: MPC timeout and optimality tolerance optimality_tolerance = Float64(get(settings, "optimality_tolerance", 0.001)) - # ---- Hard-coded MPC config ---- - # Full-year, 24-h horizon, year-end wrap-around, 60 s/iter solver cap. + # TODO: MPC horizons and timeout are currently hard coded time_steps_per_hour = Int(get(settings, "time_steps_per_hour", 1)) length_of_data = 8760 * time_steps_per_hour horizon = 24 * time_steps_per_hour - per_iter_timeout_s = 60.0 - - # ---- Guarantee presence of the tech / load sub-dicts ---- - # Done up front so PV/load resolution and sizing can mutate them in - # place, and downstream code can use plain indexing. - pv = get!(d, "PV", Dict()) - bess = get!(d, "ElectricStorage", Dict()) - eload = get!(d, "ElectricLoad", Dict()) - et = get(d, "ElectricTariff", Dict()) - eutil = get(d, "ElectricUtility", Dict()) - - # ---- Resolve PV production factor series ---- - # If not user-provided, derive via REopt.jl PVWatts and cache into `d` - # so both the sizing pre-step and downstream main REopt run reuse it - # without a second PVWatts call. - pv_prod_factor = if haskey(pv, "production_factor_series") && !isempty(pv["production_factor_series"]) - # Broadcast-convert: handles Vector{Any} from JSON parsing - # (convert(Vector{Float64}, ::Vector{Any}) would throw). - Float64.(pv["production_factor_series"]) + per_iter_timeout_s = 30.0 + + # TODO: Should MPC handle multiple PVs? + pv = get!(d, "PV", Dict()) + electric_storage = get!(d, "ElectricStorage", Dict()) + electric_load = get!(d, "ElectricLoad", Dict()) + electric_tariff = get(d, "ElectricTariff", Dict()) + electric_utility = get(d, "ElectricUtility", Dict()) + + # Read timeseries PV production factors or generate using PVWatts, save generated values to reduce PVWatts calls + if haskey(pv, "production_factor_series") && !isempty(pv["production_factor_series"]) + pv_prod_factor = Float64.(pv["production_factor_series"]) else - series = _mpc_generate_pv_production_factor(d, time_steps_per_hour) - pv["production_factor_series"] = series - series + # TODO: These production factors don't consider degradation, problem? + # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? + pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) + pv["production_factor_series"] = pv_prod_factor end - # ---- Resolve electric load series ---- - # Mirror the PV pattern: if user-provided, use it; else derive via - # REopt.jl ElectricLoad (DOE CRB path) and cache into `d`. - load_series = if haskey(eload, "loads_kw") && !isempty(eload["loads_kw"]) - Float64.(eload["loads_kw"]) + # Read timeseries electric load inputs or generate using CRBs + if haskey(electric_load, "loads_kw") && !isempty(electric_load["loads_kw"]) + loads_kw = Float64.(electric_load["loads_kw"]) else - series = _mpc_generate_loads_kw(d, time_steps_per_hour) - eload["loads_kw"] = series - series + loads_kw = generate_loads_kw(d, time_steps_per_hour) + electric_load["loads_kw"] = loads_kw end - # ---- Length checks (8760 or 8784 * tsh) ---- - # No mutation of user-supplied series. A leap-year-length input - # (8784*tsh) is accepted as-is; `_mpc_slice_with_wrap` reads the extra - # day from it for year-end look-ahead instead of wrapping to Jan 1. - pv_prod_factor = _mpc_check_series_length("PV.production_factor_series", - pv_prod_factor, time_steps_per_hour, length_of_data) - load_series = _mpc_check_series_length("ElectricLoad.loads_kw", - load_series, time_steps_per_hour, length_of_data) - - # ---- Resolve PV and ElectricStorage sizes ---- - # Runs AFTER PV/load resolution so the (possibly internal) sizing - # REopt run reuses the cached series instead of re-deriving them. - # Either pulls sizes from min_kw==max_kw / min_kwh==max_kwh, or runs - # a sizing REopt and locks the resolved values into `d` so the - # downstream main REopt run uses the SAME sizes. - sizes = _mpc_resolve_sizes!(d) - pv_kw = sizes.pv_kw - bess_kw = sizes.bess_kw - bess_kwh = sizes.bess_kwh - - soc_init = Float64(get(bess, "soc_init_fraction", 0.5)) - soc_min = Float64(get(bess, "soc_min_fraction", 0.2)) - # REopt ElectricStorage exposes three component efficiencies; MPC's - # MPCElectricStorage uses combined charge/discharge round-trip halves. - # Match REopt's own composition: charge = rectifier * sqrt(internal), - # discharge = inverter * sqrt(internal). Defaults from REopt.jl. - rect_eff = Float64(get(bess, "rectifier_efficiency_fraction", 0.96)) - inv_eff = Float64(get(bess, "inverter_efficiency_fraction", 0.96)) - int_eff = Float64(get(bess, "internal_efficiency_fraction", 0.975)) + # Check data series lengths + # TODO: Do API inputs validation when calling the MPC endpoint directly? + # Should we even have an MPC endpoint? + pv_prod_factor = check_series_length("PV.production_factor_series", pv_prod_factor, length_of_data) + loads_kw = check_series_length("ElectricLoad.loads_kw", loads_kw, length_of_data) + + # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. + technology_sizes = get_technology_sizes!(d) + pv_kw = technology_sizes.pv_kw + batt_kw = technology_sizes.batt_kw + batt_kwh = technology_sizes.batt_kwh + + # TODO: Should these defaults be read from somewhere so that they don't have to be updated in various places? + soc_0 = Float64(get(electric_storage, "soc_init_fraction", 0.5)) + soc_min = Float64(get(electric_storage, "soc_min_fraction", 0.2)) + rect_eff = Float64(get(electric_storage, "rectifier_efficiency_fraction", 0.96)) + inv_eff = Float64(get(electric_storage, "inverter_efficiency_fraction", 0.96)) + int_eff = Float64(get(electric_storage, "internal_efficiency_fraction", 0.975)) charge_eff = rect_eff * sqrt(int_eff) discharge_eff = inv_eff * sqrt(int_eff) - # Year for URDB schedule decoding is sourced from ElectricLoad.year - # (the canonical year input in REopt; ElectricTariff's `year` kwarg is - # an internal pass-through, not a user input). - tariff_year = haskey(eload, "year") ? Int(eload["year"]) : nothing - tariff = _mpc_build_tariff_arrays(et, tariff_year, time_steps_per_hour, length_of_data) - energy_rates = tariff.energy_rates - tou_demand_rates = tariff.tou_demand_rates # one per ratchet, $/kW - tou_demand_ts_all = tariff.tou_demand_time_steps # full-year indices per ratchet - monthly_demand_rates = tariff.monthly_demand_rates # length 12, $/kW per month - - # Optional emissions series — broadcast zero if not provided. - emissions = haskey(eutil, "emissions_factor_series_lb_CO2_per_kwh") ? - Float64.(eutil["emissions_factor_series_lb_CO2_per_kwh"]) : - zeros(Float64, length_of_data) - emissions = _mpc_check_series_length("ElectricUtility.emissions_factor_series_lb_CO2_per_kwh", - emissions, time_steps_per_hour, length_of_data) - - # ---- Demand tracking state ---- - # Reverse indices into the calendar: each timestep maps to its - # 1-based month (always defined) and 1-based TOU ratchet (0 if no - # ratchet applies). Both are vector lookups (O(1)) used inside the - # rolling-horizon loop to build window-local time-step groupings - # and to attribute realized peaks to the right month/tier. + # Process utility rate + year = haskey(electric_load, "year") ? Int(electric_load["year"]) : nothing + tariff = get_tariff_inputs(electric_tariff, year, time_steps_per_hour) + energy_rates = tariff.energy_rates + tou_demand_rates = tariff.tou_demand_rates + tou_demand_ratchet_time_steps = tariff.tou_demand_ratchet_time_steps + monthly_demand_rates = tariff.monthly_demand_rates + + # TODO: MPC loop cherry picked one specific emissions type - remove or keep and add others? + # TODO: If keep, add function to pull defaults? Currently only allowing user upload + emissions = haskey(electric_utility, "emissions_factor_series_lb_CO2_per_kwh") ? + Float64.(electric_utility["emissions_factor_series_lb_CO2_per_kwh"]) : zeros(Float64, length_of_data) + emissions = check_series_length("ElectricUtility.emissions_factor_series_lb_CO2_per_kwh", emissions, length_of_data) + month_starts = get_month_transition_timesteps(time_steps_per_hour) + + # ts_to_month = 8760 array specifying which month each timestep falls in (1-12) ts_to_month = Vector{Int}(undef, length_of_data) for m in 1:12 s = month_starts[m] @@ -435,28 +331,21 @@ function get_mpc_results(d::Dict)::Dict ts_to_month[s:e] .= m end - ts_to_tier = zeros(Int, length_of_data) - for (t, ratchet_ts) in enumerate(tou_demand_ts_all), g in ratchet_ts + # ts_to_ratchet = 8760 array specifying which ratchet each timestep falls in + ts_to_ratchet = zeros(Int, length_of_data) + for (t, ratchet_ts) in enumerate(tou_demand_ratchet_time_steps), g in ratchet_ts if 1 <= g <= length_of_data - ts_to_tier[g] = t + ts_to_ratchet[g] = t end end - n_tou = length(tou_demand_rates) - # `monthly_previous_peak_demands` and `tou_previous_peak_demands` are - # passed into every MPC post AND mutated after each solve so that - # subsequent windows "see" the realized peaks so far. Both reset at - # month boundaries (matches REopt billing semantics; multi-month - # ratchet/lookback support would require a different reset cadence). - tou_previous_peak_demands = zeros(Float64, n_tou) - monthly_previous_peak_demands = zeros(Float64, 12) - monthly_demand_cost_total = 0.0 - tou_demand_cost_total = 0.0 - tou_peaks_by_month = Vector{Vector{Float64}}() - monthly_peaks = Float64[] - - # ---- Dispatch accumulators (1 value per executed timestep) ---- - dispatch = Dict( + # TODO: Monthly demand has never been tested + n_tou_ratchets = length(tou_demand_rates) # Number of TOU ratchets + tou_previous_peak_demands = zeros(Float64, n_tou_ratchets) # Tracks past TOU peak demand per ratchet + monthly_previous_peak_demands = zeros(Float64, 12) # Tracks past monthly peak demand + + # Saved dispatch series (first timestep of each MPC loop) + dispatch_series = Dict( "PV" => Dict( "electric_to_load_series_kw" => Float64[], "electric_to_storage_series_kw" => Float64[], @@ -476,178 +365,151 @@ function get_mpc_results(d::Dict)::Dict "load_series_kw" => Float64[], ), ) - cost_series = Float64[] + energy_cost_series = Float64[] total_energy_cost = 0.0 - bess_soc_init_fraction = soc_init - - # ---- Build the (reused) MPC post template ---- - # Note: no Settings block here; the solver is constructed below per - # window and passed directly to run_mpc(model, post). All four demand - # inputs (TOU + monthly rates and previous-peak vectors) are passed - # so the optimizer's objective accounts for *both* charges; omitting - # `monthly_*` would silently optimize without the monthly demand - # contribution and over-state savings under monthly-demand tariffs. - function build_mpc_post(window_pv, window_load, window_rates, window_emissions, - window_tou_ts, window_monthly_ts, - tou_prev_peaks, monthly_prev_peaks, soc_init_frac) + soc_init_frac = soc_0 + + # Build MPC post + function build_mpc_post(current_horizon_pv, current_horizon_load, current_horizon_energy_rates, + current_horizon_emissions, current_horizon_tou_ts, current_horizon_monthly_ts, + tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac) return Dict( "PV" => Dict( "size_kw" => pv_kw, - "production_factor_series" => window_pv, + "production_factor_series" => current_horizon_pv, ), "ElectricStorage" => Dict( - "size_kw" => bess_kw, - "size_kwh" => bess_kwh, + "size_kw" => batt_kw, + "size_kwh" => batt_kwh, "charge_efficiency" => charge_eff, "discharge_efficiency" => discharge_eff, "soc_init_fraction" => soc_init_frac, "soc_min_fraction" => soc_min, ), "ElectricLoad" => Dict( - "loads_kw" => window_load, + "loads_kw" => current_horizon_load, ), "ElectricTariff" => Dict( - "energy_rates" => window_rates, + "energy_rates" => current_horizon_energy_rates, "tou_demand_rates" => tou_demand_rates, - "tou_demand_time_steps" => window_tou_ts, - "tou_previous_peak_demands" => tou_prev_peaks, + "tou_demand_ratchet_time_steps" => current_horizon_tou_ts, + "tou_previous_peak_demands" => tou_previous_peak_demands, "monthly_demand_rates" => monthly_demand_rates, - "time_steps_monthly" => window_monthly_ts, - "monthly_previous_peak_demands" => monthly_prev_peaks, + "time_steps_monthly" => current_horizon_monthly_ts, + "monthly_previous_peak_demands" => monthly_previous_peak_demands, ), "ElectricUtility" => Dict( - "emissions_factor_series_lb_CO2_per_kwh" => window_emissions, + "emissions_factor_series_lb_CO2_per_kwh" => current_horizon_emissions, ), ) end - @info "MPC: starting rolling-horizon loop ($(length_of_data) iterations, horizon=$(horizon))" + @info "MPC: starting rolling-horizon optimization ($(length_of_data) iterations, horizon = $(horizon) timesteps)" for idx in 1:length_of_data end_ts = idx + horizon - 1 - window_len = end_ts - idx + 1 - - window_pv = _mpc_slice_with_wrap(pv_prod_factor, idx, end_ts) - window_load = _mpc_slice_with_wrap(load_series, idx, end_ts) - window_rates = _mpc_slice_with_wrap(energy_rates, idx, end_ts) - window_emiss = _mpc_slice_with_wrap(emissions, idx, end_ts) - - # Window-local time-step groupings, both built from a single pass - # over the horizon. TOU buckets are 1 vector per ratchet (length - # n_tou); monthly buckets are 1 vector per month (length 12). - # Global indices wrap on `length_of_data` (8760·tsh) so the month - # / tier of a wrapped step matches its calendar position. - window_tou_ts = [Int[] for _ in 1:n_tou] - window_monthly_ts = [Int[] for _ in 1:12] - for k in 1:window_len + + current_horizon_pv = slice_data(pv_prod_factor, idx, end_ts) + current_horizon_load = slice_data(loads_kw, idx, end_ts) + current_horizon_energy_rates = slice_data(energy_rates, idx, end_ts) + current_horizon_emissions = slice_data(emissions, idx, end_ts) + + # List of length n_tou_ratchets, specifies which ts of the current horizon are in each TOU ratchet + # by placing values 1 to horizon into the corresponding element of the array based on ratchet number + current_horizon_tou_ts = [Int[] for _ in 1:n_tou_ratchets] + + # 12 element list, each element for one month of the year. Specifies which timesteps of the current horizon are + # in each month by placing values 1 - horizon into the corresponding element of the array based on month number + current_horizon_monthly_ts = [Int[] for _ in 1:12] + for k in 1:horizon g = idx + k - 1 if g > length_of_data g -= length_of_data end - push!(window_monthly_ts[ts_to_month[g]], k) - tier = ts_to_tier[g] - if tier > 0 - push!(window_tou_ts[tier], k) + push!(current_horizon_monthly_ts[ts_to_month[g]], k) + ratchet = ts_to_ratchet[g] + if ratchet > 0 + push!(current_horizon_tou_ts[ratchet], k) end end - post = build_mpc_post(window_pv, window_load, window_rates, window_emiss, - window_tou_ts, window_monthly_ts, - tou_previous_peak_demands, monthly_previous_peak_demands, - bess_soc_init_fraction) + post = build_mpc_post(current_horizon_pv, current_horizon_load, current_horizon_energy_rates, + current_horizon_emissions, current_horizon_tou_ts, current_horizon_monthly_ts, + tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac) model = get_solver_model(get_solver_model_type(solver_name), SolverAttributes(per_iter_timeout_s, optimality_tolerance)) result = reoptjl.run_mpc(model, post) - # ---- Extract first-timestep dispatch (perfect-forecast) ---- + # Assume perfect forecast; save first timestep of results as the executed state pv_res = result["PV"] - bess_res = result["ElectricStorage"] + batt_res = result["ElectricStorage"] util_res = result["ElectricUtility"] pv_to_load = pv_res["electric_to_load_series_kw"][1] - pv_to_bess = pv_res["electric_to_storage_series_kw"][1] + pv_to_batt = pv_res["electric_to_storage_series_kw"][1] pv_to_grid = haskey(pv_res, "electric_to_grid_series_kw") ? pv_res["electric_to_grid_series_kw"][1] : 0.0 pv_curtailed = haskey(pv_res, "electric_curtailed_series_kw") ? pv_res["electric_curtailed_series_kw"][1] : 0.0 - bess_to_load = bess_res["storage_to_load_series_kw"][1] - bess_soc = bess_res["soc_series_fraction"][1] + batt_to_load = batt_res["storage_to_load_series_kw"][1] + batt_soc = batt_res["soc_series_fraction"][1] util_to_load = util_res["electric_to_load_series_kw"][1] - util_to_bess = util_res["electric_to_storage_series_kw"][1] - grid_power = max(util_to_load + util_to_bess, 0.0) - - push!(dispatch["PV"]["electric_to_load_series_kw"], pv_to_load) - push!(dispatch["PV"]["electric_to_storage_series_kw"], pv_to_bess) - push!(dispatch["PV"]["electric_to_grid_series_kw"], pv_to_grid) - push!(dispatch["PV"]["electric_curtailed_series_kw"], pv_curtailed) - push!(dispatch["ElectricStorage"]["storage_to_load_series_kw"], bess_to_load) - push!(dispatch["ElectricStorage"]["soc_series_fraction"], round(bess_soc, digits=6)) - push!(dispatch["ElectricUtility"]["electric_to_load_series_kw"], util_to_load) - push!(dispatch["ElectricUtility"]["electric_to_storage_series_kw"], util_to_bess) - push!(dispatch["ElectricUtility"]["emissions_series_lb_CO2"], + util_to_batt = util_res["electric_to_storage_series_kw"][1] + grid_power = max(util_to_load + util_to_batt, 0.0) + + push!(dispatch_series["PV"]["electric_to_load_series_kw"], pv_to_load) + push!(dispatch_series["PV"]["electric_to_storage_series_kw"], pv_to_batt) + push!(dispatch_series["PV"]["electric_to_grid_series_kw"], pv_to_grid) + push!(dispatch_series["PV"]["electric_curtailed_series_kw"], pv_curtailed) + push!(dispatch_series["ElectricStorage"]["storage_to_load_series_kw"], batt_to_load) + push!(dispatch_series["ElectricStorage"]["soc_series_fraction"], batt_soc) + push!(dispatch_series["ElectricUtility"]["electric_to_load_series_kw"], util_to_load) + push!(dispatch_series["ElectricUtility"]["electric_to_storage_series_kw"], util_to_batt) + push!(dispatch_series["ElectricUtility"]["emissions_series_lb_CO2"], emissions[idx] * grid_power / time_steps_per_hour) - push!(dispatch["ElectricLoad"]["load_series_kw"], load_series[idx]) + push!(dispatch_series["ElectricLoad"]["load_series_kw"], loads_kw[idx]) - # ---- Energy cost ---- + # Running energy costs step_energy_cost = grid_power * energy_rates[idx] / time_steps_per_hour - push!(cost_series, step_energy_cost) + push!(energy_cost_series, step_energy_cost) total_energy_cost += step_energy_cost - # ---- Carry state ---- - bess_soc_init_fraction = bess_soc - - # ---- Demand peak tracking (per-tier and per-month) ---- - # Attribute realized peak grid power to its TOU ratchet (if any) - # and to its calendar month. Identity comes from the reverse - # indices, NOT from rate values — so coincident-rate ratchets - # and equal-rate months remain separate billing pools. - m_now = ts_to_month[idx] - monthly_previous_peak_demands[m_now] = - max(grid_power, monthly_previous_peak_demands[m_now]) - - if n_tou > 0 - tier_now = ts_to_tier[idx] - if tier_now > 0 - tou_previous_peak_demands[tier_now] = - max(grid_power, tou_previous_peak_demands[tier_now]) - end - end + soc_init_frac = batt_soc + + # Update monthly and TOU peak demand as max(current ts grid_power, previous max) + current_month = ts_to_month[idx] + monthly_previous_peak_demands[current_month] = max(grid_power, monthly_previous_peak_demands[current_month]) - # ---- Month transition: close out the just-ended month ---- - # Both monthly and TOU peaks reset at month boundaries (matches - # REopt billing semantics). The last timestep of month m is - # detected by m_now != ts_to_month[idx+1]; we handle Dec via - # idx == length_of_data. - is_month_end = (idx == length_of_data) || (ts_to_month[idx + 1] != m_now) - if is_month_end - push!(monthly_peaks, monthly_previous_peak_demands[m_now]) - monthly_demand_cost_total += - monthly_previous_peak_demands[m_now] * monthly_demand_rates[m_now] - monthly_previous_peak_demands[m_now] = 0.0 - - if n_tou > 0 - push!(tou_peaks_by_month, copy(tou_previous_peak_demands)) - tou_demand_cost_total += sum(tou_previous_peak_demands .* tou_demand_rates) - fill!(tou_previous_peak_demands, 0.0) + if n_tou_ratchets > 0 + current_ratchet = ts_to_ratchet[idx] + if current_ratchet > 0 + tou_previous_peak_demands[current_ratchet] = max(grid_power, tou_previous_peak_demands[current_ratchet]) end end + end - @info "MPC: loop complete. total_energy_cost=\$$(round(total_energy_cost, digits=2))" + @info "MPC looping completed." + + # Calculate final demand costs + monthly_demand_cost_total = sum(monthly_previous_peak_demands .* monthly_demand_rates) + tou_demand_cost_total = n_tou_ratchets > 0 ? + sum(tou_previous_peak_demands .* tou_demand_rates) : 0.0 return Dict( "MPC" => Dict( "time_steps_per_hour" => time_steps_per_hour, "horizon" => horizon, ), - "PV" => Dict("size_kw" => pv_kw), - "ElectricStorage" => Dict("size_kw" => bess_kw, "size_kwh" => bess_kwh), - "dispatch" => dispatch, + "PV" => merge(Dict("size_kw" => pv_kw), dispatch_series["PV"]), + "ElectricStorage" => merge(Dict("size_kw" => batt_kw, "size_kwh" => batt_kwh), dispatch_series["ElectricStorage"]), + "ElectricUtility" => dispatch_series["ElectricUtility"], + "ElectricLoad" => dispatch_series["ElectricLoad"], "ElectricTariff" => Dict( "total_energy_cost" => total_energy_cost, - "energy_cost_series_per_timestep" => cost_series, + "energy_cost_series_per_timestep" => energy_cost_series, "total_tou_demand_cost" => tou_demand_cost_total, "total_monthly_demand_cost" => monthly_demand_cost_total, - "tou_peaks_by_month_kw" => tou_peaks_by_month, - "monthly_peaks_kw" => monthly_peaks, + "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, + "monthly_peaks_kw" => monthly_previous_peak_demands, ), "status" => "optimal", "reopt_version" => string(pkgversion(reoptjl)), From 3e695b30806950c7c7f7cdb7859e9d1b7b78da29 Mon Sep 17 00:00:00 2001 From: lixiangk1 <72464565+lixiangk1@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:29:54 -0600 Subject: [PATCH 03/28] Update MPC variable names --- julia_src/http.jl | 9 +++++---- julia_src/mpc.jl | 7 +++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index b03a742f8..3f83c18d4 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -74,18 +74,19 @@ function reopt(req::HTTP.Request) Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" end + @info "HERE1" # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- - # When ElectricStorage.dispatch_options == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. + # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. # Runs after the Xpress availability check so the resolved solver_name is passed in. electric_storage = get(d, "ElectricStorage", Dict()) - if get(electric_storage, "dispatch_options", nothing) == "daily_foresight_optimized" + if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." mpc_results = get_mpc_results(d; solver_name=solver_name) - soc = mpc_results["dispatch"]["ElectricStorage"]["soc_series_fraction"] + soc = mpc_results["ElectricStorage"]["soc_series_fraction"] d["ElectricStorage"]["fixed_soc_series_fraction"] = soc - d["ElectricStorage"]["dispatch_options"] = "custom" + d["ElectricStorage"]["dispatch_strategy"] = "custom_soc" catch e @error "MPC pre-solve failed" exception=(e, catch_backtrace()) return HTTP.Response(500, JSON.json(Dict( diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index af376cdbe..f9916b955 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -193,7 +193,7 @@ function get_technology_sizes!(d::Dict) # Delete inputs specific to the heuristic battery dispatch run if haskey(sizing_post, "ElectricStorage") - delete!(sizing_post["ElectricStorage"], "dispatch_options") + delete!(sizing_post["ElectricStorage"], "dispatch_strategy") delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") end @@ -219,6 +219,7 @@ function get_technology_sizes!(d::Dict) batt_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) batt_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) + # TODO: If no battery is sized, just return the optimal REopt result if batt_kw <= 0.0 || batt_kwh <= 0.0 error("MPC: Optimal REopt sizing does not include ElectricStorage (size_kw=$(batt_kw), size_kwh=$(batt_kwh)). " * "The daily_foresight_optimized dispatch option requires a non-zero battery size. " * @@ -283,7 +284,9 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict loads_kw = Float64.(electric_load["loads_kw"]) else loads_kw = generate_loads_kw(d, time_steps_per_hour) - electric_load["loads_kw"] = loads_kw + + # Previously caching to save an extra CRB call but loads_kw conflicts with other load inputs + # electric_load["loads_kw"] = loads_kw end # Check data series lengths From 40d32dba8e85ba60200d162785ae390bdc829aa2 Mon Sep 17 00:00:00 2001 From: lixiangk1 <72464565+lixiangk1@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:51:24 -0600 Subject: [PATCH 04/28] Remove print statement --- julia_src/http.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 3f83c18d4..e13d04679 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -74,7 +74,6 @@ function reopt(req::HTTP.Request) Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" end - @info "HERE1" # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. From dd45a05b17379f9b69efdf3faf25937096071ed4 Mon Sep 17 00:00:00 2001 From: lixiangk1 <72464565+lixiangk1@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:55:20 -0600 Subject: [PATCH 05/28] Skip MPC dispatch if battery is not optimally sized --- julia_src/http.jl | 16 +++++++++++----- julia_src/mpc.jl | 49 +++++++++++++++++++++++++++++++---------------- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index e13d04679..ee5b99cc4 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -76,16 +76,22 @@ function reopt(req::HTTP.Request) # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. - # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. - # Runs after the Xpress availability check so the resolved solver_name is passed in. + # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. If the user does not + # specify a fixed PV/battery size, optimally size the technologies using REopt (skip MPC dispatch if optimal battery size is 0). electric_storage = get(d, "ElectricStorage", Dict()) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." mpc_results = get_mpc_results(d; solver_name=solver_name) - soc = mpc_results["ElectricStorage"]["soc_series_fraction"] - d["ElectricStorage"]["fixed_soc_series_fraction"] = soc - d["ElectricStorage"]["dispatch_strategy"] = "custom_soc" + # TODO: Cache sizing run results and avoid a second call to REopt? Are those the same results? + if get(mpc_results, "skip_mpc", false) == true + @info "Cannot execute daily_foresight_optimized battery dispatch: optimal battery size is 0." + delete!(d["ElectricStorage"], "dispatch_strategy") + else + soc = mpc_results["ElectricStorage"]["soc_series_fraction"] + d["ElectricStorage"]["fixed_soc_series_fraction"] = soc + d["ElectricStorage"]["dispatch_strategy"] = "custom_soc" + end catch e @error "MPC pre-solve failed" exception=(e, catch_backtrace()) return HTTP.Response(500, JSON.json(Dict( diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index f9916b955..3798ee226 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -185,7 +185,7 @@ function get_technology_sizes!(d::Dict) batt_kw = Float64(batt["min_kw"]) batt_kwh = Float64(batt["min_kwh"]) @info "MPC: fixed technology sizes entered, PV = $(pv_kw) kW, battery = $(batt_kw) kW / $(batt_kwh) kWh." - return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh) + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) end @info "MPC: PV and/or ElectricStorage sizes are not specified — running REopt sizing first." @@ -197,34 +197,45 @@ function get_technology_sizes!(d::Dict) delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") end - settings = get!(sizing_post, "Settings", Dict()) - settings["run_bau"] = false + # TODO: Avoid redefining defaults here? + settings = get(sizing_post, "Settings", Dict()) + sizing_solver_name = get(settings, "solver_name", "HiGHS") + sizing_timeout = Float64(get(settings, "timeout_seconds", 420)) + sizing_opt_tol = Float64(get(settings, "optimality_tolerance", 0.001)) - # TODO: Okay to hard code defaults here? solver_name check is redundant if function is only called from get_mpc_results - settings["solver_name"] = get(settings, "solver_name", "HiGHS") - settings["timeout_seconds"] = get(settings, "timeout_seconds", 420) - settings["optimality_tolerance"] = get(settings, "optimality_tolerance", 0.001) + solver_attributes = SolverAttributes(sizing_timeout, sizing_opt_tol) + m = get_solver_model(get_solver_model_type(sizing_solver_name), solver_attributes) - solver_attributes = SolverAttributes(settings["timeout_seconds"], settings["optimality_tolerance"]) - m = get_solver_model(get_solver_model_type(settings["solver_name"]), solver_attributes) + # Delete Settings inputs specific to the API + api_only_settings_keys = ("timeout_seconds", "optimality_tolerance", "run_bau") + if haskey(sizing_post, "Settings") + for k in api_only_settings_keys + delete!(sizing_post["Settings"], k) + end + if isempty(sizing_post["Settings"]) + delete!(sizing_post, "Settings") + end + end model_inputs = reoptjl.REoptInputs(sizing_post) sizing_results = reoptjl.run_reopt(m, model_inputs) if get(sizing_results, "status", "") != "optimal" - error("MPC sizing pre-step did not solve (status = $(get(sizing_results, "status", "unknown"))).") + status = get(sizing_results, "status", "unknown") + msgs = get(sizing_results, "Messages", Dict()) + errs = get(msgs, "errors", []) + warns = get(msgs, "warnings", []) + error("MPC sizing pre-step did not solve (status = $(status)). " * + "REopt errors: $(errs). REopt warnings: $(warns).") end pv_kw = Float64(get(get(sizing_results, "PV", Dict()), "size_kw", 0.0)) batt_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) batt_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) - # TODO: If no battery is sized, just return the optimal REopt result + # Skip the MPC loop if no battery is sized if batt_kw <= 0.0 || batt_kwh <= 0.0 - error("MPC: Optimal REopt sizing does not include ElectricStorage (size_kw=$(batt_kw), size_kwh=$(batt_kwh)). " * - "The daily_foresight_optimized dispatch option requires a non-zero battery size. " * - "Set ElectricStorage.min_kw == ElectricStorage.max_kw and " * - "ElectricStorage.min_kwh == ElectricStorage.max_kwh to fix sizes manually.") + return (pv_kw = pv_kw, batt_kw = 0.0, batt_kwh = 0.0, skip_mpc = true) end pv["min_kw"] = pv_kw @@ -235,7 +246,7 @@ function get_technology_sizes!(d::Dict) batt["max_kwh"] = batt_kwh @info "MPC: REopt sizing solved with PV = $(pv_kw) kW and battery = $(batt_kw) kW / $(batt_kwh) kWh." - return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh) + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) end function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict @@ -297,6 +308,12 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. technology_sizes = get_technology_sizes!(d) + + # Skip MPC if no battery is optimally sized + if technology_sizes.skip_mpc + return Dict("skip_mpc" => true) + end + pv_kw = technology_sizes.pv_kw batt_kw = technology_sizes.batt_kw batt_kwh = technology_sizes.batt_kwh From 3d259af522e92e2dcb3f8ec500c59052db2355f6 Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 24 Jun 2026 09:40:48 -0600 Subject: [PATCH 06/28] Update Manifest.toml --- julia_src/Manifest.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index dc825f89c..47ac83bda 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -948,7 +948,9 @@ version = "1.11.0" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "e807b62ab249fca7b59ac6ab7bb71725f504cc5f" +git-tree-sha1 = "1e54ef8f58ddcf9d66f392cb76fdd78123d7d2ad" +repo-rev = "dispatch-options" +repo-url = "https://github.com/NatLabRockies/REopt.jl.git" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" version = "0.59.2" From 855f18902f9189bd998f5da9664f924c44207002 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 11:15:25 -0600 Subject: [PATCH 07/28] validation and help text --- julia_src/http.jl | 42 +++++++++++++++++++++++++++------------- julia_src/mpc.jl | 49 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index ee5b99cc4..04a4bfa5e 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -75,9 +75,9 @@ function reopt(req::HTTP.Request) end # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- - # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", first run the MPC rolling-horizon loop to get an SOC profile. - # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. If the user does not - # specify a fixed PV/battery size, optimally size the technologies using REopt (skip MPC dispatch if optimal battery size is 0). + # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", if needed, first run REopt to get optimal sizing of PV and battery. + # Then run the MPC rolling-horizon loop to get a SOC profile (skip MPC dispatch if optimal battery size is 0). + # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. electric_storage = get(d, "ElectricStorage", Dict()) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @@ -85,8 +85,8 @@ function reopt(req::HTTP.Request) mpc_results = get_mpc_results(d; solver_name=solver_name) # TODO: Cache sizing run results and avoid a second call to REopt? Are those the same results? if get(mpc_results, "skip_mpc", false) == true - @info "Cannot execute daily_foresight_optimized battery dispatch: optimal battery size is 0." - delete!(d["ElectricStorage"], "dispatch_strategy") + @info "Cannot execute daily_foresight_optimized battery dispatch because optimal battery size is 0. Setting dispatch strategy to 'optimized'." + d["ElectricStorage"]["dispatch_strategy"] = "optimized" else soc = mpc_results["ElectricStorage"]["soc_series_fraction"] d["ElectricStorage"]["fixed_soc_series_fraction"] = soc @@ -841,7 +841,29 @@ function job_no_xpress(req::HTTP.Request) return HTTP.Response(500, JSON.json(error_response)) end +""" + mpc(req::HTTP.Request) +HTTP endpoint for rolling-horizon Model Predictive Control (MPC) dispatch optimization. + +This endpoint performs a full-year rolling-horizon MPC dispatch for PV + ElectricStorage systems, +optimizing daily dispatch using a 24-hour look-ahead window. Runs the MPC dispatch loop via +`get_mpc_results` and returns dispatch results and cost metrics as JSON. + +Arguments: + req::HTTP.Request: REopt inputs dictionary + +Returns JSON dictionary containing: + - MPC: Metadata (time_steps_per_hour, horizon) + - PV: Size and dispatch series (to load, storage, grid, curtailed) + - ElectricStorage: Sizes and state-of-charge series + - ElectricUtility: Grid dispatch series and emissions + - ElectricLoad: Load profile used + - ElectricTariff: Energy and demand costs, peak demands by month/ratchet + - status: "optimal" + - reopt_version: Version of REopt.jl used + +""" function mpc(req::HTTP.Request) d = JSON.parse(String(req.body)) error_response = Dict() @@ -853,14 +875,8 @@ function mpc(req::HTTP.Request) ENV["NREL_DEVELOPER_API_KEY"] = test_nrel_developer_api_key delete!(d, "api_key") end - settings = get(d, "Settings", Dict()) - solver_name = get(settings, "solver_name", "HiGHS") - if solver_name == "Xpress" && !(xpress_installed=="True") - solver_name = "HiGHS" - @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. - Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." - end - results = get_mpc_results(d; solver_name=solver_name) + + results = get_mpc_results(d) catch e @error "MPC failed" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 3798ee226..d1b0a12e4 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -249,19 +249,60 @@ function get_technology_sizes!(d::Dict) return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) end -function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict +function get_mpc_results(d::Dict)::Dict """ Run a full-year rolling-horizon MPC dispatch for PV + ElectricStorage by calling `REopt.run_mpc` once per timestep with a 24-hour look-ahead. Inputs: d::Dict, REopt inputs dictionary - solver_name::String, solver to use ("HiGHS", "Cbc", "SCIP", or "Xpress") - Returns a Dict with PV and BATT sizes, dispatch time series, and cost metrics + Returns a Dict with PV and ElectricStorage sizes, dispatch time series, and cost metrics """ - + + ## Validation on allowable inputs for MPC ## + # Error if any techs other than PV and ElectricStorage are provided + mpc_allowed_keys = Set(["PV", "ElectricStorage", "ElectricLoad", "ElectricTariff", "ElectricUtility", "Site", "Settings", "Financial"]) + unsupported_keys = setdiff(keys(d), mpc_allowed_keys) + if !isempty(unsupported_keys) + error("When using MPC (daily_foresight_optimized dispatch), only PV and ElectricStorage are supported technologies. " * + "Unsupported inputs found: $(join(unsupported_keys, ", ")).") + end + + # Error if unsupported CO2/renewable-fraction constraints are set + _site_input = get(d, "Site", Dict()) + if !isnothing(get(_site_input, "CO2_emissions_reduction_min_fraction", nothing)) + error("MPC: Site.CO2_emissions_reduction_min_fraction is not supported in MPC runs.") + end + if get(_site_input, "include_grid_renewable_fraction_in_RE_constraints", false) == true + error("MPC: Site.include_grid_renewable_fraction_in_RE_constraints is not supported in MPC runs.") + end + if get(_site_input, "include_exported_elec_emissions_in_total", true) == false + error("MPC: Site.include_exported_elec_emissions_in_total = false is not supported in MPC runs.") + end + if get(_site_input, "include_exported_renewable_electricity_in_total", true) == false + error("MPC: Site.include_exported_renewable_electricity_in_total = false is not supported in MPC runs.") + end + + # TODO: Add warnings for REopt inputs and scenarios that are not modeled in MPC (e.g., coincident peak charges, demand lookback, etc.) + @warn "Using MPC to determine dispatch. MPC does not model: tiered electricity rates; rates will be flattened to the first tier." + + # TODO: Test with outage inputs before enabling this warning. + # # Warning for outage inputs (MPC does not model outages) + # _utility_input = get(d, "ElectricUtility", Dict()) + # if any(k -> haskey(_utility_input, k), ("outage_start_time_step", "outage_start_time_steps", "outage_durations")) + # @warn "MPC: Outage inputs detected (outage_start_time_step, outage_start_time_steps, outage_durations). " * + # "MPC does not model outages; these inputs will be ignored." + # end + + ## Set up MPC inputs ## settings = get!(d, "Settings", Dict()) + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && !(xpress_installed=="True") + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. + Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." + end settings["solver_name"] = solver_name # TODO: MPC timeout and optimality tolerance From e25fadd875fd60eef108055911f468630b149d8d Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 11:16:54 -0600 Subject: [PATCH 08/28] rm solver_name from get_mpc_results inputs --- julia_src/http.jl | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 04a4bfa5e..617ef89b7 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -66,14 +66,6 @@ function reopt(req::HTTP.Request) delete!(d, "api_key") end - settings = d["Settings"] - solver_name = get(settings, "solver_name", "HiGHS") - if solver_name == "Xpress" && !(xpress_installed=="True") - solver_name = "HiGHS" - @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time - Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" - end - # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", if needed, first run REopt to get optimal sizing of PV and battery. # Then run the MPC rolling-horizon loop to get a SOC profile (skip MPC dispatch if optimal battery size is 0). @@ -82,7 +74,7 @@ function reopt(req::HTTP.Request) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." - mpc_results = get_mpc_results(d; solver_name=solver_name) + mpc_results = get_mpc_results(d) # TODO: Cache sizing run results and avoid a second call to REopt? Are those the same results? if get(mpc_results, "skip_mpc", false) == true @info "Cannot execute daily_foresight_optimized battery dispatch because optimal battery size is 0. Setting dispatch strategy to 'optimized'." From 7d4cfaa7dd8d42664964d7c8c0b590d01f4a1ad9 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 16:37:25 -0600 Subject: [PATCH 09/28] condense input processing undo solver_name change --- julia_src/http.jl | 22 ++++- julia_src/mpc.jl | 239 +++++++++++++--------------------------------- 2 files changed, 87 insertions(+), 174 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 617ef89b7..7d6c2bd40 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -66,6 +66,14 @@ function reopt(req::HTTP.Request) delete!(d, "api_key") end + settings = d["Settings"] + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && !(xpress_installed=="True") + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time + Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" + end + # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", if needed, first run REopt to get optimal sizing of PV and battery. # Then run the MPC rolling-horizon loop to get a SOC profile (skip MPC dispatch if optimal battery size is 0). @@ -74,7 +82,7 @@ function reopt(req::HTTP.Request) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." - mpc_results = get_mpc_results(d) + mpc_results = get_mpc_results(d; solver_name=solver_name) # TODO: Cache sizing run results and avoid a second call to REopt? Are those the same results? if get(mpc_results, "skip_mpc", false) == true @info "Cannot execute daily_foresight_optimized battery dispatch because optimal battery size is 0. Setting dispatch strategy to 'optimized'." @@ -846,7 +854,7 @@ Arguments: req::HTTP.Request: REopt inputs dictionary Returns JSON dictionary containing: - - MPC: Metadata (time_steps_per_hour, horizon) + - MPC: Metadata (time_steps_per_hour, horizon_time_steps) - PV: Size and dispatch series (to load, storage, grid, curtailed) - ElectricStorage: Sizes and state-of-charge series - ElectricUtility: Grid dispatch series and emissions @@ -868,7 +876,15 @@ function mpc(req::HTTP.Request) delete!(d, "api_key") end - results = get_mpc_results(d) + settings = d["Settings"] + solver_name = get(settings, "solver_name", "HiGHS") + if solver_name == "Xpress" && !(xpress_installed=="True") + solver_name = "HiGHS" + @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time + Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" + end + + results = get_mpc_results(d; solver_name=solver_name) catch e @error "MPC failed" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index d1b0a12e4..86b4952a2 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -43,61 +43,12 @@ function slice_data(arr::AbstractVector, idx::Int, end_idx::Int) end end -""" - check_series_length(name, series, time_steps_per_hour, length_of_data) - -Validate that a user-entered time series is of length 8760 * time_steps_per_hour (cannot be a leap year). -""" -function check_series_length(name::String, series::AbstractVector, length_of_data::Int) - n = length(series) - if n == length_of_data - return series - end - error("MPC: $name length $n != 8760 * time_steps_per_hour ($length_of_data).") -end - -""" - get_tariff_inputs(electric_tariff, year, time_steps_per_hour) - -Call REopt.ElectricTariff to get energy_rates, monthly_demand_rates, tou_demand_rates, tou_demand_ratchet_time_steps -MPC does not currently model tiers (tiers are flattened), coincident peak charges, or demand lookback. -""" -function get_tariff_inputs(electric_tariff::Dict, year::Union{Int,Nothing}, time_steps_per_hour::Int) - - # TODO: Are all of these relevant? Any missing inputs? - # TODO: Implement lookback? (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now - # TODO: Need to think through NEM or passing back export values (wholesale_rate, export_rate_beyond_net_metering_limit) - tariff_kwargs = (:urdb_label, :urdb_response, :urdb_utility_name, - :urdb_rate_name, :urdb_metadata, - :wholesale_rate, :export_rate_beyond_net_metering_limit, - :monthly_energy_rates, :monthly_demand_rates, - :blended_annual_energy_rate, :blended_annual_demand_rate, - :add_monthly_rates_to_urdb_rate, - :tou_energy_rates_per_kwh, :add_tou_energy_rates_to_urdb_rate, - :demand_lookback_months, :demand_lookback_percent, :demand_lookback_range) - kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in electric_tariff if Symbol(k) in tariff_kwargs) - - # MPC does not currently model tiered rates - kwargs[:remove_tiers] = true - tariff = reoptjl.ElectricTariff(; year = year, time_steps_per_hour = time_steps_per_hour, kwargs...) - - energy_rates = Float64.(tariff.energy_rates[:, 1]) - monthly_demand_rates = isempty(tariff.monthly_demand_rates) ? - zeros(Float64, 12) : Float64.(tariff.monthly_demand_rates[:, 1]) - tou_demand_rates = isempty(tariff.tou_demand_rates) ? Float64[] : Float64.(tariff.tou_demand_rates[:, 1]) - tou_demand_ratchet_time_steps = [Int.(v) for v in tariff.tou_demand_ratchet_time_steps] - - # TODO: Track lookback variables - demand_lookback_months, demand_lookback_percent, demand_lookback_range? - return (energy_rates = energy_rates, - monthly_demand_rates = monthly_demand_rates, - tou_demand_rates = tou_demand_rates, - tou_demand_ratchet_time_steps = tou_demand_ratchet_time_steps) -end """ generate_pv_production_factors(d, time_steps_per_hour) -Generate a PV production factor series using PVWatts by calling REopt.get_production_factor +Generate a PV production factor series using PVWatts by calling REopt.get_production_factor. +This is called by get_mpc_results only when the user does not provide a custom production_factor_series. """ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) @@ -122,45 +73,13 @@ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) return Vector{Float64}(pv_production_factor_series) end -""" - generate_loads_kw(d, time_steps_per_hour) - -Generate an electric load profile using DOE Commercial Reference buildings by calling REopt.ElectricLoad -""" -function generate_loads_kw(d::Dict, time_steps_per_hour::Int) - site = get(d, "Site", Dict()) - if !haskey(site, "latitude") - error("MPC: Site.latitude is required to generate ElectricLoad.loads_kw using a DOE CRB profile.") - end - if !haskey(site, "longitude") - error("MPC: Site.longitude is required to generate ElectricLoad.loads_kw using a DOE CRB profile.") - end - lat = Float64(site["latitude"]) - lon = Float64(site["longitude"]) - - @info "MPC: ElectricLoad.loads_kw not provided; generating via REopt.jl ElectricLoad (lat=$(lat), lon=$(lon))." - - electric_load = get(d, "ElectricLoad", Dict()) - - # TODO: Double check this list for relevance/missing inputs - load_kwargs = (:normalize_and_scale_load_profile_input, - :path_to_csv, :doe_reference_name, - :blended_doe_reference_names, :blended_doe_reference_percents, - :year, :city, :annual_kwh, :monthly_totals_kwh, - :monthly_peaks_kw, :loads_kw_is_net) - kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in electric_load if Symbol(k) in load_kwargs) - load = reoptjl.ElectricLoad(; latitude = lat, longitude = lon, - time_steps_per_hour = time_steps_per_hour, kwargs...) - return Vector{Float64}(load.loads_kw) -end - """ get_technology_sizes!(d) Determine PV and ElectricStorage sizes for the MPC loop. If min_kw != max_kw and/or min_kwh != max_kwh, call REopt to size technologies. User input battery sizes must also be greater than zero. """ -function get_technology_sizes!(d::Dict) +function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") pv = get!(d, "PV", Dict()) batt = get!(d, "ElectricStorage", Dict()) @@ -176,6 +95,7 @@ function get_technology_sizes!(d::Dict) "to run the daily_foresight_optimized dispatch option.") end + # Check if both PV and BESS sizes fixed pv_fixed = is_fixed(pv, "min_kw", "max_kw") batt_fixed = is_fixed(batt, "min_kw", "max_kw") && is_fixed(batt, "min_kwh", "max_kwh") @@ -184,7 +104,6 @@ function get_technology_sizes!(d::Dict) pv_kw = Float64(pv["min_kw"]) batt_kw = Float64(batt["min_kw"]) batt_kwh = Float64(batt["min_kwh"]) - @info "MPC: fixed technology sizes entered, PV = $(pv_kw) kW, battery = $(batt_kw) kW / $(batt_kwh) kWh." return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) end @@ -197,25 +116,15 @@ function get_technology_sizes!(d::Dict) delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") end - # TODO: Avoid redefining defaults here? + # TODO: Should we "remove tiers" here for sizing or allow for optimizing with tiers? + settings = get(sizing_post, "Settings", Dict()) - sizing_solver_name = get(settings, "solver_name", "HiGHS") - sizing_timeout = Float64(get(settings, "timeout_seconds", 420)) - sizing_opt_tol = Float64(get(settings, "optimality_tolerance", 0.001)) - - solver_attributes = SolverAttributes(sizing_timeout, sizing_opt_tol) - m = get_solver_model(get_solver_model_type(sizing_solver_name), solver_attributes) - - # Delete Settings inputs specific to the API - api_only_settings_keys = ("timeout_seconds", "optimality_tolerance", "run_bau") - if haskey(sizing_post, "Settings") - for k in api_only_settings_keys - delete!(sizing_post["Settings"], k) - end - if isempty(sizing_post["Settings"]) - delete!(sizing_post, "Settings") - end - end + delete!(settings, "run_bau") # Remove run_bau from sizing run + timeout_seconds = pop!(settings, "timeout_seconds", 420) + optimality_tolerance = pop!(settings, "optimality_tolerance", 0.001) + solver_attributes = SolverAttributes(timeout_seconds, optimality_tolerance) + + m = get_solver_model(get_solver_model_type(solver_name), solver_attributes) model_inputs = reoptjl.REoptInputs(sizing_post) sizing_results = reoptjl.run_reopt(m, model_inputs) @@ -249,15 +158,24 @@ function get_technology_sizes!(d::Dict) return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) end -function get_mpc_results(d::Dict)::Dict +function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict """ Run a full-year rolling-horizon MPC dispatch for PV + ElectricStorage by calling `REopt.run_mpc` once per timestep with a 24-hour look-ahead. Inputs: - d::Dict, REopt inputs dictionary + d::Dict, REopt inputs dictionary + + Returns JSON dictionary containing: + - MPC: Metadata (time_steps_per_hour, horizon_time_steps) + - PV: Size and dispatch series (to load, storage, grid, curtailed) + - ElectricStorage: Sizes and state-of-charge series + - ElectricUtility: Grid dispatch series and emissions + - ElectricLoad: Load profile used + - ElectricTariff: Energy and demand costs, peak demands by month/ratchet + - status: "optimal" + - reopt_version: Version of REopt.jl used - Returns a Dict with PV and ElectricStorage sizes, dispatch time series, and cost metrics """ ## Validation on allowable inputs for MPC ## @@ -297,12 +215,6 @@ function get_mpc_results(d::Dict)::Dict ## Set up MPC inputs ## settings = get!(d, "Settings", Dict()) - solver_name = get(settings, "solver_name", "HiGHS") - if solver_name == "Xpress" && !(xpress_installed=="True") - solver_name = "HiGHS" - @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. - Next time specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'." - end settings["solver_name"] = solver_name # TODO: MPC timeout and optimality tolerance @@ -314,38 +226,10 @@ function get_mpc_results(d::Dict)::Dict horizon = 24 * time_steps_per_hour per_iter_timeout_s = 30.0 - # TODO: Should MPC handle multiple PVs? - pv = get!(d, "PV", Dict()) - electric_storage = get!(d, "ElectricStorage", Dict()) - electric_load = get!(d, "ElectricLoad", Dict()) - electric_tariff = get(d, "ElectricTariff", Dict()) - electric_utility = get(d, "ElectricUtility", Dict()) - - # Read timeseries PV production factors or generate using PVWatts, save generated values to reduce PVWatts calls - if haskey(pv, "production_factor_series") && !isempty(pv["production_factor_series"]) - pv_prod_factor = Float64.(pv["production_factor_series"]) - else - # TODO: These production factors don't consider degradation, problem? - # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? - pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) - pv["production_factor_series"] = pv_prod_factor - end - - # Read timeseries electric load inputs or generate using CRBs - if haskey(electric_load, "loads_kw") && !isempty(electric_load["loads_kw"]) - loads_kw = Float64.(electric_load["loads_kw"]) - else - loads_kw = generate_loads_kw(d, time_steps_per_hour) - - # Previously caching to save an extra CRB call but loads_kw conflicts with other load inputs - # electric_load["loads_kw"] = loads_kw - end - - # Check data series lengths - # TODO: Do API inputs validation when calling the MPC endpoint directly? - # Should we even have an MPC endpoint? - pv_prod_factor = check_series_length("PV.production_factor_series", pv_prod_factor, length_of_data) - loads_kw = check_series_length("ElectricLoad.loads_kw", loads_kw, length_of_data) + # Call REoptInputs once upfront to process and validate inputs + # This handles: load profile generation, tariff processing (including tier removal), emissions defaults, storage efficiency defaults + i = reoptjl.REoptInputs(d) + s = i.s # Access the processed Scenario struct # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. technology_sizes = get_technology_sizes!(d) @@ -355,41 +239,54 @@ function get_mpc_results(d::Dict)::Dict return Dict("skip_mpc" => true) end - pv_kw = technology_sizes.pv_kw - batt_kw = technology_sizes.batt_kw - batt_kwh = technology_sizes.batt_kwh - - # TODO: Should these defaults be read from somewhere so that they don't have to be updated in various places? - soc_0 = Float64(get(electric_storage, "soc_init_fraction", 0.5)) - soc_min = Float64(get(electric_storage, "soc_min_fraction", 0.2)) - rect_eff = Float64(get(electric_storage, "rectifier_efficiency_fraction", 0.96)) - inv_eff = Float64(get(electric_storage, "inverter_efficiency_fraction", 0.96)) - int_eff = Float64(get(electric_storage, "internal_efficiency_fraction", 0.975)) + # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values + if !isempty(s.pvs[1].production_factor_series) + pv_prod_factor = Float64.(s.pvs[1].production_factor_series) + else + # TODO: These production factors don't consider degradation, problem? + # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? + pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) + end + + loads_kw = Float64.(s.electric_load.loads_kw) + + # Extract tariff inputs relevant to MPC (use first tier only if tiered rates) + # TODO: Are all of these relevant? Any missing inputs? + # TODO: Implement lookback? (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now + # TODO: Need to think through NEM or passing back export values (wholesale_rate, export_rate_beyond_net_metering_limit) + # TODO: Track lookback variables - demand_lookback_months, demand_lookback_percent, demand_lookback_range? + energy_rates = Float64.(s.electric_tariff.energy_rates[:, 1]) + monthly_demand_rates = isempty(s.electric_tariff.monthly_demand_rates) ? + zeros(Float64, 12) : Float64.(s.electric_tariff.monthly_demand_rates[:, 1]) + tou_demand_rates = isempty(s.electric_tariff.tou_demand_rates) ? Float64[] : Float64.(s.electric_tariff.tou_demand_rates[:, 1]) + tou_demand_ratchet_time_steps = [Int.(v) for v in s.electric_tariff.tou_demand_ratchet_time_steps] + + # Extract storage efficiency and SOC defaults from processed inputs + rect_eff = Float64(s.storage.attr["ElectricStorage"].rectifier_efficiency_fraction) + inv_eff = Float64(s.storage.attr["ElectricStorage"].inverter_efficiency_fraction) + int_eff = Float64(s.storage.attr["ElectricStorage"].internal_efficiency_fraction) charge_eff = rect_eff * sqrt(int_eff) discharge_eff = inv_eff * sqrt(int_eff) + soc_0 = Float64(s.storage.attr["ElectricStorage"].soc_init_fraction) + soc_min = Float64(s.storage.attr["ElectricStorage"].soc_min_fraction) + + # Extract emissions defaults (or use user input if provided) + co2_grid_emissions_series = Float64.(s.electric_utility.emissions_factor_series_lb_CO2_per_kwh) - # Process utility rate - year = haskey(electric_load, "year") ? Int(electric_load["year"]) : nothing - tariff = get_tariff_inputs(electric_tariff, year, time_steps_per_hour) - energy_rates = tariff.energy_rates - tou_demand_rates = tariff.tou_demand_rates - tou_demand_ratchet_time_steps = tariff.tou_demand_ratchet_time_steps - monthly_demand_rates = tariff.monthly_demand_rates - # TODO: MPC loop cherry picked one specific emissions type - remove or keep and add others? - # TODO: If keep, add function to pull defaults? Currently only allowing user upload - emissions = haskey(electric_utility, "emissions_factor_series_lb_CO2_per_kwh") ? - Float64.(electric_utility["emissions_factor_series_lb_CO2_per_kwh"]) : zeros(Float64, length_of_data) - emissions = check_series_length("ElectricUtility.emissions_factor_series_lb_CO2_per_kwh", emissions, length_of_data) + + pv_kw = technology_sizes.pv_kw + batt_kw = technology_sizes.batt_kw + batt_kwh = technology_sizes.batt_kwh month_starts = get_month_transition_timesteps(time_steps_per_hour) # ts_to_month = 8760 array specifying which month each timestep falls in (1-12) ts_to_month = Vector{Int}(undef, length_of_data) for m in 1:12 - s = month_starts[m] + s_idx = month_starts[m] e = m < 12 ? month_starts[m+1] - 1 : length_of_data - ts_to_month[s:e] .= m + ts_to_month[s_idx:e] .= m end # ts_to_ratchet = 8760 array specifying which ratchet each timestep falls in @@ -472,7 +369,7 @@ function get_mpc_results(d::Dict)::Dict current_horizon_pv = slice_data(pv_prod_factor, idx, end_ts) current_horizon_load = slice_data(loads_kw, idx, end_ts) current_horizon_energy_rates = slice_data(energy_rates, idx, end_ts) - current_horizon_emissions = slice_data(emissions, idx, end_ts) + current_horizon_emissions = slice_data(co2_grid_emissions_series, idx, end_ts) # List of length n_tou_ratchets, specifies which ts of the current horizon are in each TOU ratchet # by placing values 1 to horizon into the corresponding element of the array based on ratchet number @@ -525,7 +422,7 @@ function get_mpc_results(d::Dict)::Dict push!(dispatch_series["ElectricUtility"]["electric_to_load_series_kw"], util_to_load) push!(dispatch_series["ElectricUtility"]["electric_to_storage_series_kw"], util_to_batt) push!(dispatch_series["ElectricUtility"]["emissions_series_lb_CO2"], - emissions[idx] * grid_power / time_steps_per_hour) + co2_grid_emissions_series[idx] * grid_power / time_steps_per_hour) push!(dispatch_series["ElectricLoad"]["load_series_kw"], loads_kw[idx]) # Running energy costs @@ -558,7 +455,7 @@ function get_mpc_results(d::Dict)::Dict return Dict( "MPC" => Dict( "time_steps_per_hour" => time_steps_per_hour, - "horizon" => horizon, + "horizon_time_steps" => horizon, ), "PV" => merge(Dict("size_kw" => pv_kw), dispatch_series["PV"]), "ElectricStorage" => merge(Dict("size_kw" => batt_kw, "size_kwh" => batt_kwh), dispatch_series["ElectricStorage"]), From 9fb665df0918273a27b894378ca32ff608aff3f3 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 16:39:39 -0600 Subject: [PATCH 10/28] rmv lat long validation --- julia_src/mpc.jl | 6 ------ 1 file changed, 6 deletions(-) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 86b4952a2..f9ec0a8e8 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -52,12 +52,6 @@ This is called by get_mpc_results only when the user does not provide a custom p """ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) - if !haskey(site, "latitude") - error("MPC: Site.latitude is required to generate PV.production_factor_series using PVWatts.") - end - if !haskey(site, "longitude") - error("MPC: Site.longitude is required to generate PV.production_factor_series using PVWatts.") - end lat = Float64(site["latitude"]) lon = Float64(site["longitude"]) From 7518e3606a79d971c8424b709a22fda334d5ee01 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 17:04:54 -0600 Subject: [PATCH 11/28] get pv prod from REopt run too --- julia_src/mpc.jl | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index f9ec0a8e8..c3d55f49f 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -135,6 +135,7 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") pv_kw = Float64(get(get(sizing_results, "PV", Dict()), "size_kw", 0.0)) batt_kw = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kw", 0.0)) batt_kwh = Float64(get(get(sizing_results, "ElectricStorage", Dict()), "size_kwh", 0.0)) + pv_production_factor_series = get(get(sizing_results, "PV", Dict()), "production_factor_series", nothing) # Skip the MPC loop if no battery is sized if batt_kw <= 0.0 || batt_kwh <= 0.0 @@ -149,7 +150,7 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") batt["max_kwh"] = batt_kwh @info "MPC: REopt sizing solved with PV = $(pv_kw) kW and battery = $(batt_kw) kW / $(batt_kwh) kWh." - return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false, pv_production_factor_series = pv_production_factor_series) end function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict @@ -220,10 +221,16 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict horizon = 24 * time_steps_per_hour per_iter_timeout_s = 30.0 - # Call REoptInputs once upfront to process and validate inputs - # This handles: load profile generation, tariff processing (including tier removal), emissions defaults, storage efficiency defaults - i = reoptjl.REoptInputs(d) - s = i.s # Access the processed Scenario struct + # Process and validate inputs using REoptInputs + try + model_inputs = reoptjl.REoptInputs(d) + @info "Successfully processed REopt inputs." + catch e + @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) + error_response["error"] = sprint(showerror, e) + end + + s = model_inputs.s # Access the processed Scenario struct # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. technology_sizes = get_technology_sizes!(d) From 9c255efb083447d314bba3353d339717af714891 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 17:26:13 -0600 Subject: [PATCH 12/28] set pv prod for final run --- julia_src/http.jl | 2 +- julia_src/mpc.jl | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 7d6c2bd40..1d7094ff8 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -77,7 +77,7 @@ function reopt(req::HTTP.Request) # ---- API-only battery heuristic dispatch strategy: "daily_foresight_optimized" ---- # When ElectricStorage.dispatch_strategy == "daily_foresight_optimized", if needed, first run REopt to get optimal sizing of PV and battery. # Then run the MPC rolling-horizon loop to get a SOC profile (skip MPC dispatch if optimal battery size is 0). - # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC before running the main REopt optimization. + # Then set ElectricStorage.fixed_soc_series_fraction = MPC SOC and fix PV and BESS sizing before running the main REopt optimization. electric_storage = get(d, "ElectricStorage", Dict()) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index c3d55f49f..d5c9c1dfe 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -139,18 +139,22 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") # Skip the MPC loop if no battery is sized if batt_kw <= 0.0 || batt_kwh <= 0.0 - return (pv_kw = pv_kw, batt_kw = 0.0, batt_kwh = 0.0, skip_mpc = true) + return (pv_kw = pv_kw, batt_kw = 0.0, batt_kwh = 0.0, skip_mpc = true, pv_production_factor_series = pv_production_factor_series) end + println(pv_kw, batt_kw, batt_kwh) + + # Fix inputs for final REopt run in http.jl pv["min_kw"] = pv_kw pv["max_kw"] = pv_kw + pv["production_factor_series"] = pv_production_factor_series batt["min_kw"] = batt_kw batt["max_kw"] = batt_kw batt["min_kwh"] = batt_kwh batt["max_kwh"] = batt_kwh @info "MPC: REopt sizing solved with PV = $(pv_kw) kW and battery = $(batt_kw) kW / $(batt_kwh) kWh." - return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false, pv_production_factor_series = pv_production_factor_series) + return (; pv_kw, batt_kw, batt_kwh, skip_mpc = false, pv_production_factor_series) end function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict @@ -243,6 +247,8 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values if !isempty(s.pvs[1].production_factor_series) pv_prod_factor = Float64.(s.pvs[1].production_factor_series) + elseif technology_sizes.pv_production_factor_series !== nothing + pv_prod_factor = Float64.(technology_sizes.pv_production_factor_series) else # TODO: These production factors don't consider degradation, problem? # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? @@ -274,8 +280,6 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict # Extract emissions defaults (or use user input if provided) co2_grid_emissions_series = Float64.(s.electric_utility.emissions_factor_series_lb_CO2_per_kwh) - - pv_kw = technology_sizes.pv_kw batt_kw = technology_sizes.batt_kw batt_kwh = technology_sizes.batt_kwh From 01c0140b9f4d525e8e266464bc1981b07f919322 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 17:43:21 -0600 Subject: [PATCH 13/28] update pv prod in final REopt run --- julia_src/http.jl | 4 ++-- julia_src/mpc.jl | 27 +++++++++++++-------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 1d7094ff8..c4a73dbd6 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -82,7 +82,7 @@ function reopt(req::HTTP.Request) if get(electric_storage, "dispatch_strategy", nothing) == "daily_foresight_optimized" try @info "Running MPC to obtain daily foresight optimized battery dispatch profile." - mpc_results = get_mpc_results(d; solver_name=solver_name) + mpc_results = get_mpc_results!(d; solver_name=solver_name) # TODO: Cache sizing run results and avoid a second call to REopt? Are those the same results? if get(mpc_results, "skip_mpc", false) == true @info "Cannot execute daily_foresight_optimized battery dispatch because optimal battery size is 0. Setting dispatch strategy to 'optimized'." @@ -884,7 +884,7 @@ function mpc(req::HTTP.Request) Specify Settings.solver_name = 'HiGHS' or 'Cbc' or 'SCIP'" end - results = get_mpc_results(d; solver_name=solver_name) + results = get_mpc_results!(d; solver_name=solver_name) catch e @error "MPC failed" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index d5c9c1dfe..bb3229a7a 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -48,7 +48,7 @@ end generate_pv_production_factors(d, time_steps_per_hour) Generate a PV production factor series using PVWatts by calling REopt.get_production_factor. -This is called by get_mpc_results only when the user does not provide a custom production_factor_series. +This is called by get_mpc_results only when the user does not provide a custom production_factor_series and REopt is not called for sizing. """ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) @@ -142,12 +142,9 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") return (pv_kw = pv_kw, batt_kw = 0.0, batt_kwh = 0.0, skip_mpc = true, pv_production_factor_series = pv_production_factor_series) end - println(pv_kw, batt_kw, batt_kwh) - # Fix inputs for final REopt run in http.jl pv["min_kw"] = pv_kw pv["max_kw"] = pv_kw - pv["production_factor_series"] = pv_production_factor_series batt["min_kw"] = batt_kw batt["max_kw"] = batt_kw batt["min_kwh"] = batt_kwh @@ -157,7 +154,7 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") return (; pv_kw, batt_kw, batt_kwh, skip_mpc = false, pv_production_factor_series) end -function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict +function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict """ Run a full-year rolling-horizon MPC dispatch for PV + ElectricStorage by calling `REopt.run_mpc` once per timestep with a 24-hour look-ahead. @@ -244,6 +241,11 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict return Dict("skip_mpc" => true) end + # Sizes from user or initial REopt run + pv_kw = technology_sizes.pv_kw + batt_kw = technology_sizes.batt_kw + batt_kwh = technology_sizes.batt_kwh + # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values if !isempty(s.pvs[1].production_factor_series) pv_prod_factor = Float64.(s.pvs[1].production_factor_series) @@ -254,6 +256,8 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) end + # Avoid another PVWatts call in the final REopt run. + d["PV"]["production_factor_series"] = pv_prod_factor loads_kw = Float64.(s.electric_load.loads_kw) @@ -267,6 +271,10 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict zeros(Float64, 12) : Float64.(s.electric_tariff.monthly_demand_rates[:, 1]) tou_demand_rates = isempty(s.electric_tariff.tou_demand_rates) ? Float64[] : Float64.(s.electric_tariff.tou_demand_rates[:, 1]) tou_demand_ratchet_time_steps = [Int.(v) for v in s.electric_tariff.tou_demand_ratchet_time_steps] + # TODO: Monthly demand has never been tested + n_tou_ratchets = length(tou_demand_rates) # Number of TOU ratchets + tou_previous_peak_demands = zeros(Float64, n_tou_ratchets) # Tracks past TOU peak demand per ratchet + monthly_previous_peak_demands = zeros(Float64, 12) # Tracks past monthly peak demand # Extract storage efficiency and SOC defaults from processed inputs rect_eff = Float64(s.storage.attr["ElectricStorage"].rectifier_efficiency_fraction) @@ -280,10 +288,6 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict # Extract emissions defaults (or use user input if provided) co2_grid_emissions_series = Float64.(s.electric_utility.emissions_factor_series_lb_CO2_per_kwh) - pv_kw = technology_sizes.pv_kw - batt_kw = technology_sizes.batt_kw - batt_kwh = technology_sizes.batt_kwh - month_starts = get_month_transition_timesteps(time_steps_per_hour) # ts_to_month = 8760 array specifying which month each timestep falls in (1-12) @@ -302,11 +306,6 @@ function get_mpc_results(d::Dict; solver_name::String="HiGHS")::Dict end end - # TODO: Monthly demand has never been tested - n_tou_ratchets = length(tou_demand_rates) # Number of TOU ratchets - tou_previous_peak_demands = zeros(Float64, n_tou_ratchets) # Tracks past TOU peak demand per ratchet - monthly_previous_peak_demands = zeros(Float64, 12) # Tracks past monthly peak demand - # Saved dispatch series (first timestep of each MPC loop) dispatch_series = Dict( "PV" => Dict( From 569b894271a0fadcc3cc8eace7eb749d77c55df0 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 17:47:53 -0600 Subject: [PATCH 14/28] Create 0118_merge_20260629_2347.py --- reoptjl/migrations/0118_merge_20260629_2347.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 reoptjl/migrations/0118_merge_20260629_2347.py diff --git a/reoptjl/migrations/0118_merge_20260629_2347.py b/reoptjl/migrations/0118_merge_20260629_2347.py new file mode 100644 index 000000000..e55228f28 --- /dev/null +++ b/reoptjl/migrations/0118_merge_20260629_2347.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.26 on 2026-06-29 23:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0117_merge_20260530_1640'), + ('reoptjl', '0117_merge_20260601_2056'), + ] + + operations = [ + ] From 13bbbed630fbd3be1443da6be8232c767cf89ff2 Mon Sep 17 00:00:00 2001 From: adfarth Date: Mon, 29 Jun 2026 17:52:15 -0600 Subject: [PATCH 15/28] add back in TODO --- julia_src/mpc.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index bb3229a7a..c1e1f9cc2 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -222,6 +222,8 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict horizon = 24 * time_steps_per_hour per_iter_timeout_s = 30.0 + # TODO: Should MPC handle multiple PVs? + # Process and validate inputs using REoptInputs try model_inputs = reoptjl.REoptInputs(d) From 2fc5065788ccc6d5226b249b8032f9348ea9416f Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 1 Jul 2026 08:17:12 -0600 Subject: [PATCH 16/28] pv prod and solver --- julia_src/mpc.jl | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index c1e1f9cc2..fad40726c 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -68,7 +68,7 @@ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) end """ - get_technology_sizes!(d) + get_technology_sizes!(d, solver_name="HiGHS") Determine PV and ElectricStorage sizes for the MPC loop. If min_kw != max_kw and/or min_kwh != max_kwh, call REopt to size technologies. User input battery sizes must also be greater than zero. @@ -236,7 +236,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict s = model_inputs.s # Access the processed Scenario struct # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. - technology_sizes = get_technology_sizes!(d) + technology_sizes = get_technology_sizes!(d, solver_name=solver_name) # Skip MPC if no battery is optimally sized if technology_sizes.skip_mpc @@ -249,17 +249,22 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict batt_kwh = technology_sizes.batt_kwh # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values - if !isempty(s.pvs[1].production_factor_series) - pv_prod_factor = Float64.(s.pvs[1].production_factor_series) - elseif technology_sizes.pv_production_factor_series !== nothing - pv_prod_factor = Float64.(technology_sizes.pv_production_factor_series) + if !isempty(s.pvs) # Get prod factors if PV considered. + if !isempty(s.pvs[1].production_factor_series) + pv_prod_factor = Float64.(s.pvs[1].production_factor_series) + elseif technology_sizes.pv_production_factor_series !== nothing + pv_prod_factor = Float64.(technology_sizes.pv_production_factor_series) + else + # TODO: These production factors don't consider degradation, problem? + # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? + # AF: none of these production factors consider degradation and I don't think it's a problem? + pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) + end + # Avoid another PVWatts call in the final REopt run. + d["PV"]["production_factor_series"] = pv_prod_factor else - # TODO: These production factors don't consider degradation, problem? - # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? - pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) + pv_prod_factor = zeros(Float64, length_of_data) end - # Avoid another PVWatts call in the final REopt run. - d["PV"]["production_factor_series"] = pv_prod_factor loads_kw = Float64.(s.electric_load.loads_kw) From 919d30cb7c5026f5c4e71556277e7d122e8a5985 Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 1 Jul 2026 08:23:27 -0600 Subject: [PATCH 17/28] utf-8 --- julia_src/http.jl | 2 +- julia_src/mpc.jl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index c4a73dbd6..8c23f8ef6 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -1,4 +1,4 @@ -using HTTP, JSON, JuMP +using HTTP, JSON, JuMP using HiGHS, Cbc, SCIP using GhpGhx import REopt as reoptjl # For REopt.jl, needed because we still have local REopt.jl module for V1/V2 diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index fad40726c..0ca203d6e 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -1,4 +1,4 @@ -# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NatLabRockies/REopt_API/blob/master/LICENSE. +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NatLabRockies/REopt_API/blob/master/LICENSE. # ============================================================================ # MPC (Model Predictive Control) endpoint # ---------------------------------------------------------------------------- From 0a557b234c71b1a93bfa8ec37a96951c55bcd41f Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 1 Jul 2026 08:35:42 -0600 Subject: [PATCH 18/28] consistent response --- julia_src/http.jl | 2 +- julia_src/mpc.jl | 79 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 8c23f8ef6..e857ba30f 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -848,7 +848,7 @@ HTTP endpoint for rolling-horizon Model Predictive Control (MPC) dispatch optimi This endpoint performs a full-year rolling-horizon MPC dispatch for PV + ElectricStorage systems, optimizing daily dispatch using a 24-hour look-ahead window. Runs the MPC dispatch loop via -`get_mpc_results` and returns dispatch results and cost metrics as JSON. +`get_mpc_results!` and returns dispatch results and cost metrics as JSON. Arguments: req::HTTP.Request: REopt inputs dictionary diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 0ca203d6e..5bf0e58c2 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -48,7 +48,7 @@ end generate_pv_production_factors(d, time_steps_per_hour) Generate a PV production factor series using PVWatts by calling REopt.get_production_factor. -This is called by get_mpc_results only when the user does not provide a custom production_factor_series and REopt is not called for sizing. +This is called by get_mpc_results! only when the user does not provide a custom production_factor_series and REopt is not called for sizing. """ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) site = get(d, "Site", Dict()) @@ -67,6 +67,23 @@ function generate_pv_production_factors(d::Dict, time_steps_per_hour::Int) return Vector{Float64}(pv_production_factor_series) end +""" + build_mpc_response(status; skip_mpc=false, messages=Dict(), result_dict=Dict()) + +Build a consistent MPC response envelope with status, version info, and messages. +Ensures all response paths (success, error, skip) have uniform structure. +""" +function build_mpc_response(status::String; skip_mpc=false, messages=Dict(), result_dict=Dict()) + response = Dict( + "status" => status, + "reopt_version" => string(pkgversion(reoptjl)), + "Messages" => messages, + "skip_mpc" => skip_mpc, + ) + # Merge result data if provided (for success case) + return merge(response, result_dict) +end + """ get_technology_sizes!(d, solver_name="HiGHS") @@ -160,17 +177,21 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict calling `REopt.run_mpc` once per timestep with a 24-hour look-ahead. Inputs: - d::Dict, REopt inputs dictionary + d::Dict, REopt inputs dictionary (will be modified in-place to store computed fixed sizes and PV production factors.) Returns JSON dictionary containing: + - status: "optimal", "error", or "skipped" + - reopt_version: Version of REopt.jl used + - Messages: Dict with optional errors, warnings, or info + - skip_mpc: Boolean indicating if MPC was skipped + + For "optimal" status, also includes: - MPC: Metadata (time_steps_per_hour, horizon_time_steps) - PV: Size and dispatch series (to load, storage, grid, curtailed) - ElectricStorage: Sizes and state-of-charge series - ElectricUtility: Grid dispatch series and emissions - ElectricLoad: Load profile used - ElectricTariff: Energy and demand costs, peak demands by month/ratchet - - status: "optimal" - - reopt_version: Version of REopt.jl used """ @@ -230,7 +251,10 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict @info "Successfully processed REopt inputs." catch e @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) - error_response["error"] = sprint(showerror, e) + return build_mpc_response( + "error", + messages = Dict("errors" => [sprint(showerror, e)]) + ) end s = model_inputs.s # Access the processed Scenario struct @@ -240,7 +264,11 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # Skip MPC if no battery is optimally sized if technology_sizes.skip_mpc - return Dict("skip_mpc" => true) + return build_mpc_response( + "skipped", + skip_mpc = true, + messages = Dict("info" => ["No battery was optimally sized in REopt pre-step; MPC dispatch not needed."]) + ) end # Sizes from user or initial REopt run @@ -463,24 +491,25 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict tou_demand_cost_total = n_tou_ratchets > 0 ? sum(tou_previous_peak_demands .* tou_demand_rates) : 0.0 - return Dict( - "MPC" => Dict( - "time_steps_per_hour" => time_steps_per_hour, - "horizon_time_steps" => horizon, - ), - "PV" => merge(Dict("size_kw" => pv_kw), dispatch_series["PV"]), - "ElectricStorage" => merge(Dict("size_kw" => batt_kw, "size_kwh" => batt_kwh), dispatch_series["ElectricStorage"]), - "ElectricUtility" => dispatch_series["ElectricUtility"], - "ElectricLoad" => dispatch_series["ElectricLoad"], - "ElectricTariff" => Dict( - "total_energy_cost" => total_energy_cost, - "energy_cost_series_per_timestep" => energy_cost_series, - "total_tou_demand_cost" => tou_demand_cost_total, - "total_monthly_demand_cost" => monthly_demand_cost_total, - "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, - "monthly_peaks_kw" => monthly_previous_peak_demands, - ), - "status" => "optimal", - "reopt_version" => string(pkgversion(reoptjl)), + return build_mpc_response( + "optimal", + result_dict = Dict( + "MPC" => Dict( + "time_steps_per_hour" => time_steps_per_hour, + "horizon_time_steps" => horizon, + ), + "PV" => merge(Dict("size_kw" => pv_kw), dispatch_series["PV"]), + "ElectricStorage" => merge(Dict("size_kw" => batt_kw, "size_kwh" => batt_kwh), dispatch_series["ElectricStorage"]), + "ElectricUtility" => dispatch_series["ElectricUtility"], + "ElectricLoad" => dispatch_series["ElectricLoad"], + "ElectricTariff" => Dict( + "total_energy_cost" => total_energy_cost, + "energy_cost_series_per_timestep" => energy_cost_series, + "total_tou_demand_cost" => tou_demand_cost_total, + "total_monthly_demand_cost" => monthly_demand_cost_total, + "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, + "monthly_peaks_kw" => monthly_previous_peak_demands, + ), + ) ) end From 0e2efc04b00c687a254ab8ae64006abb58745983 Mon Sep 17 00:00:00 2001 From: adfarth Date: Thu, 9 Jul 2026 13:02:33 -0600 Subject: [PATCH 19/28] fix inputs processing --- julia_src/mpc.jl | 57 ++++++++++--------- ...uts_fixed_soc_series_fraction_tolerance.py | 19 +++++++ 2 files changed, 48 insertions(+), 28 deletions(-) create mode 100644 reoptjl/migrations/0119_alter_electricstorageinputs_fixed_soc_series_fraction_tolerance.py diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 5bf0e58c2..3e7845717 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -85,12 +85,15 @@ function build_mpc_response(status::String; skip_mpc=false, messages=Dict(), res end """ - get_technology_sizes!(d, solver_name="HiGHS") + get_technology_sizes!(d::Dict, model_inputs::reoptjl.REoptInputs, solver_settings::Dict) Determine PV and ElectricStorage sizes for the MPC loop. If min_kw != max_kw and/or min_kwh != max_kwh, call REopt to size technologies. User input battery sizes must also be greater than zero. + d is mutated in-place to update PV and ElectricStorage sizes. + model_inputs has already been processed to remove inputs not used in REopt.jl """ -function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") +function get_technology_sizes!(d::Dict, model_inputs::reoptjl.REoptInputs, solver_settings::Dict) + # pv and batt are updated in-place from the dictionary `d` and used in the final REopt run pv = get!(d, "PV", Dict()) batt = get!(d, "ElectricStorage", Dict()) @@ -119,25 +122,11 @@ function get_technology_sizes!(d::Dict; solver_name::String="HiGHS") end @info "MPC: PV and/or ElectricStorage sizes are not specified — running REopt sizing first." - sizing_post = deepcopy(d) - - # Delete inputs specific to the heuristic battery dispatch run - if haskey(sizing_post, "ElectricStorage") - delete!(sizing_post["ElectricStorage"], "dispatch_strategy") - delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") - end - # TODO: Should we "remove tiers" here for sizing or allow for optimizing with tiers? - settings = get(sizing_post, "Settings", Dict()) - delete!(settings, "run_bau") # Remove run_bau from sizing run - timeout_seconds = pop!(settings, "timeout_seconds", 420) - optimality_tolerance = pop!(settings, "optimality_tolerance", 0.001) - solver_attributes = SolverAttributes(timeout_seconds, optimality_tolerance) - - m = get_solver_model(get_solver_model_type(solver_name), solver_attributes) + m = get_solver_model(get_solver_model_type(solver_settings["solver_name"]), solver_settings["solver_attributes"]) - model_inputs = reoptjl.REoptInputs(sizing_post) + # model_inputs = reoptjl.REoptInputs(sizing_post) sizing_results = reoptjl.run_reopt(m, model_inputs) if get(sizing_results, "status", "") != "optimal" @@ -232,10 +221,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict ## Set up MPC inputs ## settings = get!(d, "Settings", Dict()) - settings["solver_name"] = solver_name - - # TODO: MPC timeout and optimality tolerance - optimality_tolerance = Float64(get(settings, "optimality_tolerance", 0.001)) + settings["solver_name"] = solver_name # TODO: remove? # TODO: MPC horizons and timeout are currently hard coded time_steps_per_hour = Int(get(settings, "time_steps_per_hour", 1)) @@ -244,10 +230,26 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict per_iter_timeout_s = 30.0 # TODO: Should MPC handle multiple PVs? + # TODO: MPC timeout and optimality tolerance + # Update sizing_post to remove solver settings and dispatch inputs, to be able to validate inputs using REoptInputs + sizing_post = deepcopy(d) + settings = get(sizing_post, "Settings", Dict()) + solver_settings=Dict() + delete!(settings, "run_bau") # Remove run_bau from sizing run + solver_settings["timeout_seconds"] = pop!(settings, "timeout_seconds", 420) + solver_settings["optimality_tolerance"] = pop!(settings, "optimality_tolerance", 0.001) + solver_settings["solver_attributes"] = SolverAttributes(solver_settings["timeout_seconds"], solver_settings["optimality_tolerance"]) + solver_settings["solver_name"] = solver_name + # Delete inputs specific to the heuristic battery dispatch run + if haskey(sizing_post, "ElectricStorage") + delete!(sizing_post["ElectricStorage"], "dispatch_strategy") + delete!(sizing_post["ElectricStorage"], "fixed_soc_series_fraction") + end # Process and validate inputs using REoptInputs + model_inputs = nothing try - model_inputs = reoptjl.REoptInputs(d) + model_inputs = reoptjl.REoptInputs(sizing_post) @info "Successfully processed REopt inputs." catch e @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) @@ -256,11 +258,10 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict messages = Dict("errors" => [sprint(showerror, e)]) ) end - - s = model_inputs.s # Access the processed Scenario struct - + # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. - technology_sizes = get_technology_sizes!(d, solver_name=solver_name) + technology_sizes = get_technology_sizes!(d, model_inputs, solver_settings) + s = model_inputs.s # Access the processed Scenario struct # Skip MPC if no battery is optimally sized if technology_sizes.skip_mpc @@ -434,7 +435,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac) model = get_solver_model(get_solver_model_type(solver_name), - SolverAttributes(per_iter_timeout_s, optimality_tolerance)) + SolverAttributes(per_iter_timeout_s, solver_settings["optimality_tolerance"])) result = reoptjl.run_mpc(model, post) # Assume perfect forecast; save first timestep of results as the executed state diff --git a/reoptjl/migrations/0119_alter_electricstorageinputs_fixed_soc_series_fraction_tolerance.py b/reoptjl/migrations/0119_alter_electricstorageinputs_fixed_soc_series_fraction_tolerance.py new file mode 100644 index 000000000..2378b63f3 --- /dev/null +++ b/reoptjl/migrations/0119_alter_electricstorageinputs_fixed_soc_series_fraction_tolerance.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.26 on 2026-07-09 18:09 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0118_merge_20260629_2347'), + ] + + operations = [ + migrations.AlterField( + model_name='electricstorageinputs', + name='fixed_soc_series_fraction_tolerance', + field=models.FloatField(blank=True, help_text='Absolute tolerance on fixed_soc_series_fraction to avoid infeasible solutions when fixed_soc_series_fraction is provided.', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)]), + ), + ] From 41a68ab2cd3815fe9162f42dc8c0480e82c97035 Mon Sep 17 00:00:00 2001 From: adfarth Date: Thu, 9 Jul 2026 14:33:13 -0600 Subject: [PATCH 20/28] add inputs to models.py --- CHANGELOG.md | 5 ++++ ...torageinputs_dispatch_strategy_and_more.py | 24 +++++++++++++++++++ reoptjl/models.py | 18 +++++++++++++- reoptjl/test/posts/all_inputs_test.json | 1 + reoptjl/validators.py | 6 +++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 reoptjl/migrations/0120_electricstorageinputs_dispatch_strategy_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e0d1944..8a9c2292c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ Classify the change according to the following categories: ##### Removed ### Patches +## heuristic-dispatch-option +### Minor udpates +#### Added +- Add **ElectricStorage** inputs field **dispatch_options** with heuristic options + ## fixed-soc ### Minor udpates #### Added diff --git a/reoptjl/migrations/0120_electricstorageinputs_dispatch_strategy_and_more.py b/reoptjl/migrations/0120_electricstorageinputs_dispatch_strategy_and_more.py new file mode 100644 index 000000000..486adb62d --- /dev/null +++ b/reoptjl/migrations/0120_electricstorageinputs_dispatch_strategy_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.26 on 2026-07-09 20:32 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0119_alter_electricstorageinputs_fixed_soc_series_fraction_tolerance'), + ] + + operations = [ + migrations.AddField( + model_name='electricstorageinputs', + name='dispatch_strategy', + field=models.TextField(blank=True, choices=[('optimized', 'Optimized'), ('peak_shaving_look_ahead', 'Peak Shaving Look Ahead'), ('peak_shaving_look_behind', 'Peak Shaving Look Behind'), ('self_consumption', 'Self Consumption'), ('backup', 'Backup'), ('custom_soc', 'Custom Soc'), ('daily_foresight_optimized', 'Daily Foresight Optimized')], default='optimized', help_text='Electric storage dispatch strategy can be one of: optimized, peak_shaving_look_ahead, peak_shaving_look_behind, self_consumption, backup, custom_soc, daily_foresight_optimized', null=True), + ), + migrations.AlterField( + model_name='electricstorageinputs', + name='soc_min_fraction', + field=models.FloatField(blank=True, help_text='Minimum allowable battery state of charge as fraction of energy capacity.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1.0)]), + ), + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index cee7bfaa1..3efe51998 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -3668,6 +3668,16 @@ class ElectricStorageInputs(BaseModel, models.Model): primary_key=True ) + ELECTRICSTORAGE_DISPATCH_STRATEGY = models.TextChoices('ELECTRICSTORAGE_DISPATCH_STRATEGY', ( + "optimized", + "peak_shaving_look_ahead", + "peak_shaving_look_behind", + "self_consumption", + "backup", + "custom_soc", + "daily_foresight_optimized" + )) + min_kw = models.FloatField( default=0, validators=[ @@ -3731,8 +3741,14 @@ class ElectricStorageInputs(BaseModel, models.Model): blank=True, help_text="Battery rectifier efficiency" ) + dispatch_strategy = models.TextField( + default=ELECTRICSTORAGE_DISPATCH_STRATEGY.optimized, + choices=ELECTRICSTORAGE_DISPATCH_STRATEGY.choices, + null=True, + blank=True, + help_text="Electric storage dispatch strategy can be one of: optimized, peak_shaving_look_ahead, peak_shaving_look_behind, self_consumption, backup, custom_soc, daily_foresight_optimized" + ) soc_min_fraction = models.FloatField( - default=0.2, validators=[ MinValueValidator(0), MaxValueValidator(1.0) diff --git a/reoptjl/test/posts/all_inputs_test.json b/reoptjl/test/posts/all_inputs_test.json index d53def1ad..3cc26956b 100644 --- a/reoptjl/test/posts/all_inputs_test.json +++ b/reoptjl/test/posts/all_inputs_test.json @@ -167,6 +167,7 @@ "internal_efficiency_fraction": 0.975, "inverter_efficiency_fraction": 0.96, "rectifier_efficiency_fraction": 0.96, + "dispatch_strategy": "optimized", "soc_min_fraction": 0.2, "soc_min_applies_during_outages": true, "soc_init_fraction": 0.5, diff --git a/reoptjl/validators.py b/reoptjl/validators.py index 99cdc456a..76dbd852a 100644 --- a/reoptjl/validators.py +++ b/reoptjl/validators.py @@ -363,6 +363,12 @@ def update_pv_defaults_offgrid(self, pvmodel): else: self.models["ElectricStorage"].soc_init_fraction = 1.0 + if self.models["ElectricStorage"].__getattribute__("soc_min_fraction") == None: + if self.models["ElectricStorage"].dispatch_strategy=="backup": + self.models["ElectricStorage"].soc_min_fraction = 0.8 + else: + self.models["ElectricStorage"].soc_min_fraction = 0.2 + if self.models["ElectricStorage"].__getattribute__("can_grid_charge") == None: if self.models["Settings"].off_grid_flag==False: self.models["ElectricStorage"].can_grid_charge = True From e2eb18a1d1f2276d561f1222f0b6caff869358e2 Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 15 Jul 2026 13:47:51 -0600 Subject: [PATCH 21/28] add bess export io --- ...ts_can_export_beyond_nem_limit_and_more.py | 34 +++++++++++++++++++ reoptjl/models.py | 22 ++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 reoptjl/migrations/0121_electricstorageinputs_can_export_beyond_nem_limit_and_more.py diff --git a/reoptjl/migrations/0121_electricstorageinputs_can_export_beyond_nem_limit_and_more.py b/reoptjl/migrations/0121_electricstorageinputs_can_export_beyond_nem_limit_and_more.py new file mode 100644 index 000000000..d1c39e257 --- /dev/null +++ b/reoptjl/migrations/0121_electricstorageinputs_can_export_beyond_nem_limit_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.26 on 2026-07-15 19:47 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0120_electricstorageinputs_dispatch_strategy_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='electricstorageinputs', + name='can_export_beyond_nem_limit', + field=models.BooleanField(blank=True, default=False, help_text='True/False for if technology can export energy beyond the annual site load (and be compensated for that energy at the export_rate_beyond_net_metering_limit).'), + ), + migrations.AddField( + model_name='electricstorageinputs', + name='can_net_meter', + field=models.BooleanField(blank=True, default=False, help_text='True/False for if technology has option to participate in net metering agreement with utility. Note that a technology can only participate in either net metering or wholesale rates (not both).'), + ), + migrations.AddField( + model_name='electricstorageinputs', + name='can_wholesale', + field=models.BooleanField(blank=True, default=False, help_text='True/False for if technology has option to export energy that is compensated at the wholesale_rate. Note that a technology can only participate in either net metering or wholesale rates (not both).'), + ), + migrations.AddField( + model_name='electricstorageoutputs', + name='storage_to_grid_series_kw', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), blank=True, default=list, size=None), + ), + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index 3efe51998..b3d0e14f0 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -3793,6 +3793,24 @@ class ElectricStorageInputs(BaseModel, models.Model): blank=True, help_text="Flag to set whether the battery can be charged from the grid, or just onsite generation." ) + can_net_meter = models.BooleanField( + default=False, + blank=True, + help_text=("True/False for if technology has option to participate in net metering agreement with utility. " + "Note that a technology can only participate in either net metering or wholesale rates (not both).") + ) + can_wholesale = models.BooleanField( + default=False, + blank=True, + help_text=("True/False for if technology has option to export energy that is compensated at the wholesale_rate. " + "Note that a technology can only participate in either net metering or wholesale rates (not both).") + ) + can_export_beyond_nem_limit = models.BooleanField( + default=False, + blank=True, + help_text=("True/False for if technology can export energy beyond the annual site load (and be compensated for " + "that energy at the export_rate_beyond_net_metering_limit).") + ) installed_cost_per_kw = models.FloatField( default=968.0, validators=[ @@ -3978,6 +3996,10 @@ class ElectricStorageOutputs(BaseModel, models.Model): models.FloatField(null=True, blank=True), blank=True, default=list ) + storage_to_grid_series_kw = ArrayField( + models.FloatField(null=True, blank=True), + blank=True, default=list + ) initial_capital_cost = models.FloatField(null=True, blank=True) maintenance_cost = models.FloatField(null=True, blank=True) state_of_health_series_fraction = ArrayField( From 3f6d57ea255c656fa8d5260fe3d1e4b9d287e230 Mon Sep 17 00:00:00 2001 From: adfarth Date: Wed, 15 Jul 2026 13:59:12 -0600 Subject: [PATCH 22/28] Update custom_table_config.py --- reoptjl/custom_table_config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/reoptjl/custom_table_config.py b/reoptjl/custom_table_config.py index 4cd3ec479..4d52f13cc 100644 --- a/reoptjl/custom_table_config.py +++ b/reoptjl/custom_table_config.py @@ -804,6 +804,12 @@ "bau_value" : lambda df: safe_get(df, "outputs.ElectricStorage.storage_to_load_series_kw_bau"), "scenario_value": lambda df: safe_get(df, "outputs.ElectricStorage.storage_to_load_series_kw") }, + { + "label" : "Battery Exported to Grid (kWh/yr)", + "key" : "battery_exported_to_grid", + "bau_value" : lambda df: safe_get(df, "outputs.ElectricStorage.storage_to_grid_series_kw_bau"), + "scenario_value": lambda df: safe_get(df, "outputs.ElectricStorage.storage_to_grid_series_kw") + }, { "label" : "Generator Serving Load (kWh/yr)", "key" : "generator_serving_load", From 756df197e2df32191987deb0be36995c58347cc0 Mon Sep 17 00:00:00 2001 From: adfarth Date: Thu, 16 Jul 2026 13:19:34 -0600 Subject: [PATCH 23/28] fix settings and pv processing --- julia_src/http.jl | 5 ++--- julia_src/mpc.jl | 9 +++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index e857ba30f..3326447df 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -875,9 +875,8 @@ function mpc(req::HTTP.Request) ENV["NREL_DEVELOPER_API_KEY"] = test_nrel_developer_api_key delete!(d, "api_key") end - - settings = d["Settings"] - solver_name = get(settings, "solver_name", "HiGHS") + + solver_name = get(get(d, "Settings", Dict()), "solver_name", "HiGHS") if solver_name == "Xpress" && !(xpress_installed=="True") solver_name = "HiGHS" @warn "Changing solver_name from Xpress to $solver_name because Xpress is not installed. Next time diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 3e7845717..cb8d4204f 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -118,7 +118,7 @@ function get_technology_sizes!(d::Dict, model_inputs::reoptjl.REoptInputs, solve pv_kw = Float64(pv["min_kw"]) batt_kw = Float64(batt["min_kw"]) batt_kwh = Float64(batt["min_kwh"]) - return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false) + return (pv_kw = pv_kw, batt_kw = batt_kw, batt_kwh = batt_kwh, skip_mpc = false, pv_production_factor_series = nothing) end @info "MPC: PV and/or ElectricStorage sizes are not specified — running REopt sizing first." @@ -250,7 +250,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict model_inputs = nothing try model_inputs = reoptjl.REoptInputs(sizing_post) - @info "Successfully processed REopt inputs." + @info "Successfully processed REopt inputs." catch e @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) return build_mpc_response( @@ -279,7 +279,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values if !isempty(s.pvs) # Get prod factors if PV considered. - if !isempty(s.pvs[1].production_factor_series) + if !isnothing(s.pvs[1].production_factor_series) pv_prod_factor = Float64.(s.pvs[1].production_factor_series) elseif technology_sizes.pv_production_factor_series !== nothing pv_prod_factor = Float64.(technology_sizes.pv_production_factor_series) @@ -432,7 +432,8 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict post = build_mpc_post(current_horizon_pv, current_horizon_load, current_horizon_energy_rates, current_horizon_emissions, current_horizon_tou_ts, current_horizon_monthly_ts, - tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac) + tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac + ) model = get_solver_model(get_solver_model_type(solver_name), SolverAttributes(per_iter_timeout_s, solver_settings["optimality_tolerance"])) From e470e9ce00c4efb12d624d45ad986081920e8987 Mon Sep 17 00:00:00 2001 From: adfarth Date: Thu, 16 Jul 2026 14:41:58 -0600 Subject: [PATCH 24/28] update todos --- julia_src/http.jl | 1 - julia_src/mpc.jl | 23 ++++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/julia_src/http.jl b/julia_src/http.jl index 3326447df..c89172a49 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -101,7 +101,6 @@ function reopt(req::HTTP.Request) end end - #TODO: What timeout and optimality tolerance should MPC use? timeout_seconds = pop!(settings, "timeout_seconds") optimality_tolerance = pop!(settings, "optimality_tolerance") solver_attributes = SolverAttributes(timeout_seconds, optimality_tolerance) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index cb8d4204f..675e42fdb 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -192,6 +192,10 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict error("When using MPC (daily_foresight_optimized dispatch), only PV and ElectricStorage are supported technologies. " * "Unsupported inputs found: $(join(unsupported_keys, ", ")).") end + # Error if multiple PVs + if haskey(d, "PV") && length(d["PV"]) > 1 + error("MPC: Multiple PV systems are not supported.") + end # Error if unsupported CO2/renewable-fraction constraints are set _site_input = get(d, "Site", Dict()) @@ -211,6 +215,8 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # TODO: Add warnings for REopt inputs and scenarios that are not modeled in MPC (e.g., coincident peak charges, demand lookback, etc.) @warn "Using MPC to determine dispatch. MPC does not model: tiered electricity rates; rates will be flattened to the first tier." + # TODO: Error if rate tariff contains lookbacks. + # TODO: Test with outage inputs before enabling this warning. # # Warning for outage inputs (MPC does not model outages) # _utility_input = get(d, "ElectricUtility", Dict()) @@ -220,24 +226,22 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # end ## Set up MPC inputs ## - settings = get!(d, "Settings", Dict()) - settings["solver_name"] = solver_name # TODO: remove? # TODO: MPC horizons and timeout are currently hard coded + settings = get!(d, "Settings", Dict()) time_steps_per_hour = Int(get(settings, "time_steps_per_hour", 1)) length_of_data = 8760 * time_steps_per_hour horizon = 24 * time_steps_per_hour per_iter_timeout_s = 30.0 - # TODO: Should MPC handle multiple PVs? - # TODO: MPC timeout and optimality tolerance + # TODO: Handle multiple PVs # Update sizing_post to remove solver settings and dispatch inputs, to be able to validate inputs using REoptInputs sizing_post = deepcopy(d) settings = get(sizing_post, "Settings", Dict()) solver_settings=Dict() delete!(settings, "run_bau") # Remove run_bau from sizing run - solver_settings["timeout_seconds"] = pop!(settings, "timeout_seconds", 420) - solver_settings["optimality_tolerance"] = pop!(settings, "optimality_tolerance", 0.001) + solver_settings["timeout_seconds"] = pop!(settings, "timeout_seconds", 600) # only gets used in sizing run. + solver_settings["optimality_tolerance"] = pop!(settings, "optimality_tolerance", 0.001) # Update to a higher value if solve time becomes an issue solver_settings["solver_attributes"] = SolverAttributes(solver_settings["timeout_seconds"], solver_settings["optimality_tolerance"]) solver_settings["solver_name"] = solver_name # Delete inputs specific to the heuristic battery dispatch run @@ -278,15 +282,13 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict batt_kwh = technology_sizes.batt_kwh # Note: REoptInputs does not provide PV production factors if user doesn't specify custom values + # PV production is NOT levelized in MPC but IS levelized in REopt. This will result in a slight mis-match. if !isempty(s.pvs) # Get prod factors if PV considered. if !isnothing(s.pvs[1].production_factor_series) pv_prod_factor = Float64.(s.pvs[1].production_factor_series) elseif technology_sizes.pv_production_factor_series !== nothing pv_prod_factor = Float64.(technology_sizes.pv_production_factor_series) else - # TODO: These production factors don't consider degradation, problem? - # Does MPCPV need a degradation input to calculate the levelization factor used in the optimization? - # AF: none of these production factors consider degradation and I don't think it's a problem? pv_prod_factor = generate_pv_production_factors(d, time_steps_per_hour) end # Avoid another PVWatts call in the final REopt run. @@ -299,9 +301,8 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # Extract tariff inputs relevant to MPC (use first tier only if tiered rates) # TODO: Are all of these relevant? Any missing inputs? - # TODO: Implement lookback? (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now + # TODO: Implement lookback (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now # TODO: Need to think through NEM or passing back export values (wholesale_rate, export_rate_beyond_net_metering_limit) - # TODO: Track lookback variables - demand_lookback_months, demand_lookback_percent, demand_lookback_range? energy_rates = Float64.(s.electric_tariff.energy_rates[:, 1]) monthly_demand_rates = isempty(s.electric_tariff.monthly_demand_rates) ? zeros(Float64, 12) : Float64.(s.electric_tariff.monthly_demand_rates[:, 1]) From 41514227f47250f02ed0cf3cfe6bedb2bfa900d4 Mon Sep 17 00:00:00 2001 From: adfarth Date: Fri, 17 Jul 2026 13:27:52 -0700 Subject: [PATCH 25/28] account for nem and whl --- julia_src/mpc.jl | 110 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 24 deletions(-) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 675e42fdb..a85616e04 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -180,7 +180,9 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict - ElectricStorage: Sizes and state-of-charge series - ElectricUtility: Grid dispatch series and emissions - ElectricLoad: Load profile used - - ElectricTariff: Energy and demand costs, peak demands by month/ratchet + - ElectricTariff: Separate cost components (total_energy_cost, total_export_benefit, + total_tou_demand_cost, total_monthly_demand_cost), a combined total_electricity_bill, + per-timestep energy/export series, and peak demands by month/ratchet """ @@ -193,6 +195,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "Unsupported inputs found: $(join(unsupported_keys, ", ")).") end # Error if multiple PVs + # TODO: Handle multiple PVs if haskey(d, "PV") && length(d["PV"]) > 1 error("MPC: Multiple PV systems are not supported.") end @@ -234,7 +237,6 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict horizon = 24 * time_steps_per_hour per_iter_timeout_s = 30.0 - # TODO: Handle multiple PVs # Update sizing_post to remove solver settings and dispatch inputs, to be able to validate inputs using REoptInputs sizing_post = deepcopy(d) settings = get(sizing_post, "Settings", Dict()) @@ -325,6 +327,27 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # Extract emissions defaults (or use user input if provided) co2_grid_emissions_series = Float64.(s.electric_utility.emissions_factor_series_lb_CO2_per_kwh) + # --- Export / net metering setup (mirror the sizing-run scenario) --- + # NEM is enabled in MPC when ElectricUtility.net_metering_limit_kw > 0. + nm_limit_kw = Float64(s.electric_utility.net_metering_limit_kw) + + # WHL (net billing) rate: export_rates[:WHL] in the processed scenario is a negative "cost"; + # MPCElectricTariff expects a positive wholesale_rate (it negates internally). + whl_rate_full = (:WHL in s.electric_tariff.export_bins) ? + -1.0 .* Float64.(s.electric_tariff.export_rates[:WHL]) : nothing + + # PV export capability comes from the processed PV struct. + pv_can_net_meter = !isempty(s.pvs) ? Bool(s.pvs[1].can_net_meter) : false + pv_can_wholesale = !isempty(s.pvs) ? Bool(s.pvs[1].can_wholesale) : false + + # Battery export capability comes from the processed ElectricStorage struct + _batt_attr = s.storage.attr["ElectricStorage"] + batt_can_net_meter = Bool(_batt_attr.can_net_meter) + batt_can_wholesale = Bool(_batt_attr.can_wholesale) + + # NEM export is credited (approximately) at the retail energy rate; used for cost reporting below. + nem_active = nm_limit_kw > 0 && (pv_can_net_meter || batt_can_net_meter) + month_starts = get_month_transition_timesteps(time_steps_per_hour) # ts_to_month = 8760 array specifying which month each timestep falls in (1-12) @@ -353,6 +376,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict ), "ElectricStorage" => Dict( "storage_to_load_series_kw" => Float64[], + "storage_to_grid_series_kw" => Float64[], "soc_series_fraction" => Float64[], ), "ElectricUtility" => Dict( @@ -364,18 +388,37 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "load_series_kw" => Float64[], ), ) - energy_cost_series = Float64[] + energy_charge_series = Float64[] # grid purchase (energy) charges per timestep + export_benefit_series = Float64[] # NEM/WHL export credits per timestep (positive = revenue) total_energy_cost = 0.0 + total_export_benefit = 0.0 soc_init_frac = soc_0 # Build MPC post function build_mpc_post(current_horizon_pv, current_horizon_load, current_horizon_energy_rates, current_horizon_emissions, current_horizon_tou_ts, current_horizon_monthly_ts, - tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac) + tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac, + current_horizon_whl_rate) + tariff = Dict( + "energy_rates" => current_horizon_energy_rates, + "tou_demand_rates" => tou_demand_rates, + "tou_demand_ratchet_time_steps" => current_horizon_tou_ts, + "tou_previous_peak_demands" => tou_previous_peak_demands, + "monthly_demand_rates" => monthly_demand_rates, + "time_steps_monthly" => current_horizon_monthly_ts, + "monthly_previous_peak_demands" => monthly_previous_peak_demands, + ) + # WHL (net billing) export: MPCElectricTariff reads a positive `wholesale_rate` and + # builds the :WHL export bin when it is provided. + if current_horizon_whl_rate !== nothing + tariff["wholesale_rate"] = current_horizon_whl_rate + end return Dict( "PV" => Dict( "size_kw" => pv_kw, "production_factor_series" => current_horizon_pv, + "can_net_meter" => pv_can_net_meter, + "can_wholesale" => pv_can_wholesale, ), "ElectricStorage" => Dict( "size_kw" => batt_kw, @@ -384,20 +427,16 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "discharge_efficiency" => discharge_eff, "soc_init_fraction" => soc_init_frac, "soc_min_fraction" => soc_min, + "can_net_meter" => batt_can_net_meter, + "can_wholesale" => batt_can_wholesale, ), "ElectricLoad" => Dict( "loads_kw" => current_horizon_load, ), - "ElectricTariff" => Dict( - "energy_rates" => current_horizon_energy_rates, - "tou_demand_rates" => tou_demand_rates, - "tou_demand_ratchet_time_steps" => current_horizon_tou_ts, - "tou_previous_peak_demands" => tou_previous_peak_demands, - "monthly_demand_rates" => monthly_demand_rates, - "time_steps_monthly" => current_horizon_monthly_ts, - "monthly_previous_peak_demands" => monthly_previous_peak_demands, - ), + "ElectricTariff" => tariff, + # net_metering_limit_kw drives the NEM export bin in MPCElectricTariff (NEM on if > 0) "ElectricUtility" => Dict( + "net_metering_limit_kw" => nm_limit_kw, "emissions_factor_series_lb_CO2_per_kwh" => current_horizon_emissions, ), ) @@ -411,6 +450,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict current_horizon_load = slice_data(loads_kw, idx, end_ts) current_horizon_energy_rates = slice_data(energy_rates, idx, end_ts) current_horizon_emissions = slice_data(co2_grid_emissions_series, idx, end_ts) + current_horizon_whl_rate = whl_rate_full === nothing ? nothing : slice_data(whl_rate_full, idx, end_ts) # List of length n_tou_ratchets, specifies which ts of the current horizon are in each TOU ratchet # by placing values 1 to horizon into the corresponding element of the array based on ratchet number @@ -433,7 +473,8 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict post = build_mpc_post(current_horizon_pv, current_horizon_load, current_horizon_energy_rates, current_horizon_emissions, current_horizon_tou_ts, current_horizon_monthly_ts, - tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac + tou_previous_peak_demands, monthly_previous_peak_demands, soc_init_frac, + current_horizon_whl_rate ) model = get_solver_model(get_solver_model_type(solver_name), @@ -450,6 +491,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict pv_to_grid = haskey(pv_res, "electric_to_grid_series_kw") ? pv_res["electric_to_grid_series_kw"][1] : 0.0 pv_curtailed = haskey(pv_res, "electric_curtailed_series_kw") ? pv_res["electric_curtailed_series_kw"][1] : 0.0 batt_to_load = batt_res["storage_to_load_series_kw"][1] + batt_to_grid = haskey(batt_res, "storage_to_grid_series_kw") ? batt_res["storage_to_grid_series_kw"][1] : 0.0 batt_soc = batt_res["soc_series_fraction"][1] util_to_load = util_res["electric_to_load_series_kw"][1] util_to_batt = util_res["electric_to_storage_series_kw"][1] @@ -460,6 +502,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict push!(dispatch_series["PV"]["electric_to_grid_series_kw"], pv_to_grid) push!(dispatch_series["PV"]["electric_curtailed_series_kw"], pv_curtailed) push!(dispatch_series["ElectricStorage"]["storage_to_load_series_kw"], batt_to_load) + push!(dispatch_series["ElectricStorage"]["storage_to_grid_series_kw"], batt_to_grid) push!(dispatch_series["ElectricStorage"]["soc_series_fraction"], batt_soc) push!(dispatch_series["ElectricUtility"]["electric_to_load_series_kw"], util_to_load) push!(dispatch_series["ElectricUtility"]["electric_to_storage_series_kw"], util_to_batt) @@ -467,10 +510,22 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict co2_grid_emissions_series[idx] * grid_power / time_steps_per_hour) push!(dispatch_series["ElectricLoad"]["load_series_kw"], loads_kw[idx]) - # Running energy costs - step_energy_cost = grid_power * energy_rates[idx] / time_steps_per_hour - push!(energy_cost_series, step_energy_cost) - total_energy_cost += step_energy_cost + # Running electricity costs (reported as separate components; see results below) + # Energy (grid purchase) charge for this timestep + step_energy_charge = grid_power * energy_rates[idx] / time_steps_per_hour + + # Export credit for this timestep. NEM credits at the retail energy rate; otherwise WHL credits + # at the wholesale rate. NOTE: when both NEM and WHL are available the model chooses per horizon + # and the executed-timestep bin split is not returned, so this can misprice such timesteps. + step_export_kw = pv_to_grid + batt_to_grid + export_rate_idx = nem_active ? energy_rates[idx] : + (whl_rate_full !== nothing ? whl_rate_full[idx] : 0.0) + step_export_benefit = step_export_kw * export_rate_idx / time_steps_per_hour + + push!(energy_charge_series, step_energy_charge) + push!(export_benefit_series, step_export_benefit) + total_energy_cost += step_energy_charge + total_export_benefit += step_export_benefit soc_init_frac = batt_soc @@ -506,12 +561,19 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "ElectricUtility" => dispatch_series["ElectricUtility"], "ElectricLoad" => dispatch_series["ElectricLoad"], "ElectricTariff" => Dict( - "total_energy_cost" => total_energy_cost, - "energy_cost_series_per_timestep" => energy_cost_series, - "total_tou_demand_cost" => tou_demand_cost_total, - "total_monthly_demand_cost" => monthly_demand_cost_total, - "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, - "monthly_peaks_kw" => monthly_previous_peak_demands, + # --- Cost components (before tax); combine for the total bill below --- + "total_energy_cost" => total_energy_cost, # grid purchases (energy) only + "total_export_benefit" => total_export_benefit, # NEM/WHL credits (positive = revenue) + "total_tou_demand_cost" => tou_demand_cost_total, + "total_monthly_demand_cost" => monthly_demand_cost_total, + # --- Total electricity bill = energy charge - export benefit + demand charges --- + "total_electricity_bill" => total_energy_cost - total_export_benefit + + tou_demand_cost_total + monthly_demand_cost_total, + # --- Per-timestep series (energy/exports only; demand is a peak-based charge) --- + "energy_charge_series_per_timestep" => energy_charge_series, + "export_benefit_series_per_timestep" => export_benefit_series, + "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, + "monthly_peaks_kw" => monthly_previous_peak_demands, ), ) ) From 181a316ddeced82db04204c59f605c6177423bdd Mon Sep 17 00:00:00 2001 From: adfarth Date: Fri, 17 Jul 2026 14:42:38 -0700 Subject: [PATCH 26/28] updt branch --- julia_src/Manifest.toml | 4 ++-- julia_src/mpc.jl | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index 47ac83bda..1cb08ee62 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -948,11 +948,11 @@ version = "1.11.0" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "1e54ef8f58ddcf9d66f392cb76fdd78123d7d2ad" +git-tree-sha1 = "539750260abec1f7b965670e498ee2618548e148" repo-rev = "dispatch-options" repo-url = "https://github.com/NatLabRockies/REopt.jl.git" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" -version = "0.59.2" +version = "0.59.3" [[deps.Random]] deps = ["SHA"] diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index a85616e04..dcbea8fa8 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -304,7 +304,6 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # Extract tariff inputs relevant to MPC (use first tier only if tiered rates) # TODO: Are all of these relevant? Any missing inputs? # TODO: Implement lookback (demand_lookback_months, demand_lookback_percent, demand_lookback_range) Ignoring coincident peak charges for now - # TODO: Need to think through NEM or passing back export values (wholesale_rate, export_rate_beyond_net_metering_limit) energy_rates = Float64.(s.electric_tariff.energy_rates[:, 1]) monthly_demand_rates = isempty(s.electric_tariff.monthly_demand_rates) ? zeros(Float64, 12) : Float64.(s.electric_tariff.monthly_demand_rates[:, 1]) @@ -388,7 +387,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "load_series_kw" => Float64[], ), ) - energy_charge_series = Float64[] # grid purchase (energy) charges per timestep + energy_cost_series = Float64[] # grid purchase (energy) charges per timestep export_benefit_series = Float64[] # NEM/WHL export credits per timestep (positive = revenue) total_energy_cost = 0.0 total_export_benefit = 0.0 @@ -522,7 +521,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict (whl_rate_full !== nothing ? whl_rate_full[idx] : 0.0) step_export_benefit = step_export_kw * export_rate_idx / time_steps_per_hour - push!(energy_charge_series, step_energy_charge) + push!(energy_cost_series, step_energy_charge) push!(export_benefit_series, step_export_benefit) total_energy_cost += step_energy_charge total_export_benefit += step_export_benefit @@ -570,7 +569,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "total_electricity_bill" => total_energy_cost - total_export_benefit + tou_demand_cost_total + monthly_demand_cost_total, # --- Per-timestep series (energy/exports only; demand is a peak-based charge) --- - "energy_charge_series_per_timestep" => energy_charge_series, + "energy_cost_series_per_timestep" => energy_cost_series, "export_benefit_series_per_timestep" => export_benefit_series, "tou_peaks_by_ratchet_kw" => tou_previous_peak_demands, "monthly_peaks_kw" => monthly_previous_peak_demands, From ac1b14954b01e2409a3708c763c4669972497c53 Mon Sep 17 00:00:00 2001 From: adfarth Date: Fri, 17 Jul 2026 15:47:58 -0700 Subject: [PATCH 27/28] update REopt --- julia_src/Manifest.toml | 2 +- julia_src/mpc.jl | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index 1cb08ee62..ceb0887ae 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -948,7 +948,7 @@ version = "1.11.0" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "539750260abec1f7b965670e498ee2618548e148" +git-tree-sha1 = "ec4dad03ea876407e69a384cecc62c29be858405" repo-rev = "dispatch-options" repo-url = "https://github.com/NatLabRockies/REopt.jl.git" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index dcbea8fa8..22f524da5 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -194,10 +194,18 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict error("When using MPC (daily_foresight_optimized dispatch), only PV and ElectricStorage are supported technologies. " * "Unsupported inputs found: $(join(unsupported_keys, ", ")).") end - # Error if multiple PVs + # PV can be provided as a Dict (single system) or an array of Dicts. + # MPC only supports a single PV, so error on multiple PVs and normalize a + # one-element array down to a Dict so downstream code can treat d["PV"] as a Dict. # TODO: Handle multiple PVs - if haskey(d, "PV") && length(d["PV"]) > 1 - error("MPC: Multiple PV systems are not supported.") + if haskey(d, "PV") && isa(d["PV"], AbstractArray) + if length(d["PV"]) > 1 + error("MPC: Multiple PV systems are not supported.") + elseif length(d["PV"]) == 1 + d["PV"] = d["PV"][1] + else + delete!(d, "PV") # empty PV array -> treat as no PV + end end # Error if unsupported CO2/renewable-fraction constraints are set @@ -218,7 +226,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict # TODO: Add warnings for REopt inputs and scenarios that are not modeled in MPC (e.g., coincident peak charges, demand lookback, etc.) @warn "Using MPC to determine dispatch. MPC does not model: tiered electricity rates; rates will be flattened to the first tier." - # TODO: Error if rate tariff contains lookbacks. + # TODO: Error if rate tariff contains lookbacks or coincident peak charges. # TODO: Test with outage inputs before enabling this warning. # # Warning for outage inputs (MPC does not model outages) @@ -314,6 +322,9 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict tou_previous_peak_demands = zeros(Float64, n_tou_ratchets) # Tracks past TOU peak demand per ratchet monthly_previous_peak_demands = zeros(Float64, 12) # Tracks past monthly peak demand + println("monthly_demand_rates: ", monthly_demand_rates) + + # Extract storage efficiency and SOC defaults from processed inputs rect_eff = Float64(s.storage.attr["ElectricStorage"].rectifier_efficiency_fraction) inv_eff = Float64(s.storage.attr["ElectricStorage"].inverter_efficiency_fraction) From 41468b13ecad12ae46a8bc68faadd44539c13090 Mon Sep 17 00:00:00 2001 From: adfarth Date: Fri, 17 Jul 2026 16:56:30 -0700 Subject: [PATCH 28/28] update for demand rates --- julia_src/mpc.jl | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/julia_src/mpc.jl b/julia_src/mpc.jl index 22f524da5..e9f8b5c1d 100644 --- a/julia_src/mpc.jl +++ b/julia_src/mpc.jl @@ -181,7 +181,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict - ElectricUtility: Grid dispatch series and emissions - ElectricLoad: Load profile used - ElectricTariff: Separate cost components (total_energy_cost, total_export_benefit, - total_tou_demand_cost, total_monthly_demand_cost), a combined total_electricity_bill, + total_tou_demand_cost, total_non_tou_monthly_demand_cost), a combined total_electricity_bill, per-timestep energy/export series, and peak demands by month/ratchet """ @@ -264,7 +264,19 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict model_inputs = nothing try model_inputs = reoptjl.REoptInputs(sizing_post) - @info "Successfully processed REopt inputs." + + # REoptInputs returns an error Dict (rather than throwing) when input validation fails. + # Surface those messages instead of falling through to get_technology_sizes!, which expects + # a REoptInputs and would otherwise raise a confusing MethodError. + if isa(model_inputs, Dict) + @error "REopt input validation failed during MPC pre-solve." messages=get(model_inputs, "Messages", Dict()) + return build_mpc_response( + "error", + messages = get(model_inputs, "Messages", Dict("errors" => ["REopt input validation failed."])) + ) + else + @info "Successfully processed REopt inputs." + end catch e @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) return build_mpc_response( @@ -272,7 +284,9 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict messages = Dict("errors" => [sprint(showerror, e)]) ) end - + + + # MPC requires fixed PV and battery sizes. If not provided, call REopt first in a sizing run. technology_sizes = get_technology_sizes!(d, model_inputs, solver_settings) s = model_inputs.s # Access the processed Scenario struct @@ -412,7 +426,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict tariff = Dict( "energy_rates" => current_horizon_energy_rates, "tou_demand_rates" => tou_demand_rates, - "tou_demand_ratchet_time_steps" => current_horizon_tou_ts, + "tou_demand_time_steps" => current_horizon_tou_ts, "tou_previous_peak_demands" => tou_previous_peak_demands, "monthly_demand_rates" => monthly_demand_rates, "time_steps_monthly" => current_horizon_monthly_ts, @@ -575,7 +589,7 @@ function get_mpc_results!(d::Dict; solver_name::String="HiGHS")::Dict "total_energy_cost" => total_energy_cost, # grid purchases (energy) only "total_export_benefit" => total_export_benefit, # NEM/WHL credits (positive = revenue) "total_tou_demand_cost" => tou_demand_cost_total, - "total_monthly_demand_cost" => monthly_demand_cost_total, + "total_non_tou_monthly_demand_cost" => monthly_demand_cost_total, # --- Total electricity bill = energy charge - export benefit + demand charges --- "total_electricity_bill" => total_energy_cost - total_export_benefit + tou_demand_cost_total + monthly_demand_cost_total,