diff --git a/OUU.md b/OUU.md new file mode 100644 index 000000000..c9864fb89 --- /dev/null +++ b/OUU.md @@ -0,0 +1,900 @@ +# Complete OUU Implementation Summary for REopt.jl + +## Executive Summary + +Optimization Under Uncertainty (OUU) has been implemented in REopt.jl using a **two-stage stochastic programming** approach. This allows the model to make optimal investment (sizing) decisions that are robust across multiple possible future scenarios of load demand and renewable production. + +### What Was Implemented + +**Status:** Core OUU functionality operational for electric systems with PV and battery storage. + +**Key Achievement:** Users can now specify uncertainty ranges for ElectricLoad and PV production, and REopt will optimize system sizing to minimize expected costs across all scenarios. + +--- + +## How OUU Works in REopt Context + +### Two-Stage Stochastic Programming Framework + +**First Stage (Before Uncertainty Reveals):** +- **Decision:** How much capacity to install (PV size, battery power/energy capacity) +- **Timing:** Made now, before knowing which scenario will occur +- **Constraint:** Same sizing decision applies to ALL scenarios +- **Cost:** Capital costs, fixed O&M, incentives + +**Second Stage (After Uncertainty Reveals):** +- **Decision:** How to dispatch assets (production, storage charge/discharge, grid purchases) +- **Timing:** Made separately for each scenario +- **Constraint:** Different optimal dispatch for each scenario based on actual load/production +- **Cost:** Energy purchases, demand charges, variable O&M, fuel costs + +**Objective Function:** +``` +Minimize: FirstStageCosts + E[SecondStageCosts] + = (Capital + Fixed) + Σ(probability[s] × OperatingCosts[s]) +``` + +This finds a **robust solution** that performs well across all possible futures, not just the expected case. + +--- + +## Uncertainty Parameters for ElectricLoad and PV + +REopt supports two approaches to modeling uncertainty: **time-invariant** scenarios and **Monte Carlo sampling** methods. These allow you to capture different types of uncertainty in load and renewable production. + +### Uncertainty Method Overview + +**Time-Invariant Method (`method="time_invariant"`):** +- Each scenario applies the **same deviation to all timesteps** +- Models systematic, persistent uncertainty (e.g., overall building occupancy changes, general climate conditions) +- User specifies exact scenarios with their probabilities +- Best for: Known scenarios with specific probabilities, policy analysis, deterministic sensitivity studies + +**Monte Carlo Methods (`method="discrete"`, `"normal"`, or `"uniform"`):** +- Each scenario applies **different random deviations to each timestep** +- Models timestep-level variability and stochastic uncertainty +- User specifies sampling distribution and number of samples +- Best for: Capturing short-term variability, weather uncertainty, load fluctuations + +--- + +### Time-Invariant Uncertainty (Original Method) + +### ElectricLoad Uncertainty Specification + +Users can add time-invariant uncertainty to electric load in the JSON input: + +```json +{ + "ElectricLoad": { + "doe_reference_name": "LargeHotel", + "annual_kwh": 2000000.0, + "uncertainty": { + "enabled": true, + "method": "time_invariant", + "deviation_fractions": [-0.1, 0.0, 0.1], + "deviation_probabilities": [0.25, 0.50, 0.25] + } + } +} +``` + +**Parameters:** +- `enabled` (bool): Activate load uncertainty +- `method` (string): Must be `"time_invariant"` for this approach +- `deviation_fractions` (array): Fractional deviations from nominal (e.g., [-0.1, 0.0, 0.1] = -10%, nominal, +10%) + - Negative values decrease load, positive values increase load + - Zero represents the nominal case + - Can specify any number of scenarios with any deviations +- `deviation_probabilities` (array): Probability of each deviation scenario + - Must have same length as `deviation_fractions` + - Must sum to 1.0 + +**Result:** Creates scenarios equal to the length of the arrays. For the example above: +1. **Low:** 90% of nominal load profile (prob = 0.25) +2. **Middle:** 100% of nominal load profile (prob = 0.50) +3. **High:** 110% of nominal load profile (prob = 0.25) + +**Flexible Examples:** + +*Asymmetric uncertainty (5 scenarios):* +```json +"deviation_fractions": [-0.20, -0.10, 0.0, 0.15, 0.30], +"deviation_probabilities": [0.10, 0.20, 0.40, 0.20, 0.10] +``` + +*Simple two-scenario (low/high):* +```json +"deviation_fractions": [-0.15, 0.15], +"deviation_probabilities": [0.50, 0.50] +``` + +### PV Production Uncertainty Specification + +Similarly for PV production factors with time-invariant uncertainty: + +```json +{ + "PV": { + "max_kw": 2000.0, + "production_uncertainty": { + "enabled": true, + "method": "time_invariant", + "deviation_fractions": [-0.2, 0.0, 0.2], + "deviation_probabilities": [0.25, 0.50, 0.25] + } + } +} +``` + +**Parameters:** +- `enabled` (bool): Activate PV production uncertainty +- `method` (string): Must be `"time_invariant"` for this approach +- `deviation_fractions` (array): Fractional deviations from nominal production factors + - Negative values = less solar resource, positive = more solar resource + - Zero represents the nominal case +- `deviation_probabilities` (array): Probability of each scenario (must sum to 1.0) + +**Result:** Creates 3 production scenarios: +1. **Low:** 80% of nominal production factors (prob = 0.25) +2. **Middle:** 100% of nominal production factors (prob = 0.50) +3. **High:** 120% of nominal production factors (prob = 0.25) + +**Note:** The same flexible specification applies - you can define any number of scenarios with asymmetric deviations and probabilities. + +--- + +### Monte Carlo Uncertainty Methods + +REopt supports three Monte Carlo sampling methods that generate scenarios with **timestep-varying uncertainty**. Unlike time-invariant methods where each scenario applies the same deviation to all timesteps, Monte Carlo methods sample different deviations for each timestep, capturing short-term variability and stochastic uncertainty. + +#### Method 1: Discrete Distribution Sampling (`method="discrete"`) + +Samples from a discrete probability distribution at each timestep. Best for modeling uncertainty with known discrete outcomes (e.g., weather states: sunny/cloudy/rainy). + +**ElectricLoad Example:** +```json +{ + "ElectricLoad": { + "doe_reference_name": "LargeHotel", + "annual_kwh": 2000000.0, + "uncertainty": { + "enabled": true, + "method": "discrete", + "deviation_fractions": [-0.1, 0.0, 0.1], + "deviation_probabilities": [0.25, 0.50, 0.25], + "n_samples": 3 + } + } +} +``` + +**Parameters:** +- `method`: `"discrete"` +- `deviation_fractions`: Possible deviation values to sample from +- `deviation_probabilities`: Sampling probabilities for each deviation (must sum to 1.0) +- `n_samples`: Number of scenario samples to generate + +**How It Works:** +- For each of the `n_samples` scenarios: + - At each timestep, randomly sample a deviation from the discrete distribution + - Apply that deviation to the nominal value for that timestep +- Each scenario has a different sequence of deviations across timesteps +- All scenarios have equal probability (1/n_samples) + +**PV Production Example:** +```json +{ + "PV": { + "max_kw": 1000.0, + "production_uncertainty": { + "enabled": true, + "method": "discrete", + "deviation_fractions": [-0.2, 0.0, 0.2], + "deviation_probabilities": [0.30, 0.40, 0.30], + "n_samples": 5 + } + } +} +``` + +This generates 5 PV production scenarios, where each scenario has a unique sequence of deviations sampled from {-20%, 0%, +20%} with probabilities {30%, 40%, 30%} at each timestep. + +#### Method 2: Normal Distribution Sampling (`method="normal"`) + +Samples from a Normal (Gaussian) distribution at each timestep. Best for modeling continuous uncertainty with expected value and known variability. + +**ElectricLoad Example:** +```json +{ + "ElectricLoad": { + "doe_reference_name": "LargeHotel", + "annual_kwh": 2000000.0, + "uncertainty": { + "enabled": true, + "method": "normal", + "mean": 0.0, + "std": 0.10, + "n_samples": 5 + } + } +} +``` + +**Parameters:** +- `method`: `"normal"` +- `mean`: Mean of the Normal distribution (fractional deviation, typically 0.0 for unbiased uncertainty) +- `std`: Standard deviation of the Normal distribution (fractional) +- `n_samples`: Number of scenario samples to generate + +**How It Works:** +- For each timestep in each scenario, samples a deviation from Normal(mean, std) +- Deviations are unbounded in theory but typically fall within ±3σ +- Captures continuous, symmetric uncertainty around the expected value + +**Interpretation:** +- `std = 0.10` means typical deviations are ±10% (1 standard deviation) +- ~68% of sampled deviations fall within ±10% +- ~95% of sampled deviations fall within ±20% (2 standard deviations) + +**PV Production Example:** +```json +{ + "PV": { + "max_kw": 1000.0, + "production_uncertainty": { + "enabled": true, + "method": "normal", + "mean": -0.05, + "std": 0.15, + "n_samples": 10 + } + } +} +``` + +This models PV with a slight negative bias (mean = -5%) and moderate variability (std = 15%), generating 10 scenarios. + +#### Method 3: Uniform Distribution Sampling (`method="uniform"`) + +Samples from a Uniform distribution at each timestep. Best for representing maximum uncertainty or unknown distribution within known bounds. + +**ElectricLoad Example:** +```json +{ + "ElectricLoad": { + "doe_reference_name": "LargeHotel", + "annual_kwh": 2000000.0, + "uncertainty": { + "enabled": true, + "method": "uniform", + "lower_bound": -0.15, + "upper_bound": 0.15, + "n_samples": 5 + } + } +} +``` + +**Parameters:** +- `method`: `"uniform"` +- `lower_bound`: Lower bound of the uniform distribution (fractional) +- `upper_bound`: Upper bound of the uniform distribution (fractional) +- `n_samples`: Number of scenario samples to generate + +**How It Works:** +- For each timestep in each scenario, samples a deviation uniformly from [lower_bound, upper_bound] +- All deviations within the range are equally likely +- Represents maximum entropy (maximum uncertainty) within bounds + +**Interpretation:** +- Bounds of [-0.15, 0.15] mean deviations range from -15% to +15% +- No preference for any value within the range +- Mean deviation is (lower_bound + upper_bound) / 2 = 0.0 in this example + +**PV Production Example:** +```json +{ + "PV": { + "max_kw": 1000.0, + "production_uncertainty": { + "enabled": true, + "method": "uniform", + "lower_bound": -0.30, + "upper_bound": 0.20, + "n_samples": 8 + } + } +} +``` + +This models asymmetric PV uncertainty ranging from -30% to +20%, generating 8 scenarios. + +#### Combined Monte Carlo Scenarios + +When both load and PV use Monte Carlo methods, scenarios are combined multiplicatively: + +**Example: 3 load samples × 5 PV samples = 15 total scenarios** + +```json +{ + "ElectricLoad": { + "uncertainty": { + "enabled": true, + "method": "discrete", + "deviation_fractions": [-0.1, 0.0, 0.1], + "deviation_probabilities": [0.25, 0.50, 0.25], + "n_samples": 3 + } + }, + "PV": { + "production_uncertainty": { + "enabled": true, + "method": "normal", + "mean": 0.0, + "std": 0.15, + "n_samples": 5 + } + } +} +``` + +**Result:** 15 scenarios (3 × 5), each with equal probability (1/15), combining: +- One of 3 load deviation sequences +- One of 5 PV deviation sequences + +**Important:** Each load scenario has its own independent sequence of per-timestep deviations, and each PV scenario has its own independent sequence. The combination creates joint scenarios that capture both load and PV variability simultaneously. + +#### Choosing Between Methods + +| Method | Best For | Advantages | Disadvantages | +|--------|----------|------------|---------------| +| `time_invariant` | Policy analysis, known scenarios, systematic uncertainty | Interpretable, exact probabilities, efficient | Doesn't capture timestep variability | +| `discrete` | Weather states, discrete outcomes | Matches known distributions, interpretable | Requires probability specification | +| `normal` | Continuous uncertainty, measurement error | Natural for many processes, parameterized by mean/std | Can sample extreme values | +| `uniform` | Maximum uncertainty, bounded unknowns | Maximum entropy within bounds, conservative | Equal likelihood may be unrealistic | + +**Computational Note:** More samples improve statistical representation but increase solve time: +- 3-5 samples: Quick, captures basic variability +- 10-20 samples: Good balance for most applications +- 50+ samples: Detailed uncertainty quantification (expensive) + +For combined load × PV uncertainty, total scenarios = n_load_samples × n_pv_samples. Keep this product manageable (typically < 50 total scenarios) for reasonable solve times. + +--- + +### Combined Scenarios (Time-Invariant Method) + +When **both** load and PV uncertainty are enabled using the time-invariant method, scenarios are combined assuming independence: + +**Example: 9 Joint Scenarios with 3×3 Configuration** + +Using the 3-scenario specifications above: + +| Scenario | Load | PV Production | Probability | +|----------|------|---------------|-------------| +| 1 | Low (90%) | Low (80%) | 0.25 × 0.25 = 0.0625 | +| 2 | Low (90%) | Mid (100%) | 0.25 × 0.50 = 0.125 | +| 3 | Low (90%) | High (120%) | 0.25 × 0.25 = 0.0625 | +| 4 | Mid (100%) | Low (80%) | 0.50 × 0.25 = 0.125 | +| 5 | Mid (100%) | Mid (100%) | 0.50 × 0.50 = 0.25 | +| 6 | Mid (100%) | High (120%) | 0.50 × 0.25 = 0.125 | +| 7 | High (110%) | Low (80%) | 0.25 × 0.25 = 0.0625 | +| 8 | High (110%) | Mid (100%) | 0.25 × 0.50 = 0.125 | +| 9 | High (110%) | High (120%) | 0.25 × 0.25 = 0.0625 | + +**Key Insight:** Scenario 7 (high load + low PV) is the "worst case" for grid dependence, while Scenario 3 (low load + high PV) favors renewable self-consumption. + +**Flexibility:** With the array-based format, you can create any number of combined scenarios: +- 2 load × 2 PV = 4 scenarios +- 3 load × 5 PV = 15 scenarios +- 5 load × 3 PV = 15 scenarios + +The total number of scenarios = (length of load deviation_fractions) × (length of PV deviation_fractions) + +--- + +## Expected Impact of Uncertainty on Results + +### Compared to Deterministic Evaluation + +When uncertainty is added, results typically show: + +### 1. **Economically Optimal Sizing Under Uncertainty** + +**Key Insight:** OUU doesn't necessarily produce larger or smaller systems - it finds the sizing that **minimizes expected total cost** (capital + operating) across all scenarios. + +**The Economic Trade-off:** +``` +Larger System: Higher capital cost ↔ Lower expected operating costs (less grid dependence) +Smaller System: Lower capital cost ↔ Higher expected operating costs (more grid purchases) +``` + +**When OUU tends toward LARGER sizing:** +- High electricity rates (expensive to undersize and buy from grid) +- High demand charges (penalties for peak grid usage) +- Low renewable capital costs relative to grid costs +- Significant probability of unfavorable scenarios (high load/low production) +- Risk-averse cost structures (e.g., demand charges are non-linear) + +**When OUU may produce SIMILAR sizing to deterministic:** +- Symmetric probability distributions (expected case dominates) +- Moderate electricity rates +- Balanced capital vs. operating cost trade-offs +- Grid acts as reliable, reasonably-priced backup + +**When OUU could produce SMALLER sizing:** +- Very high capital costs relative to electricity rates +- Low probability of extreme scenarios +- Grid electricity is cheap and reliable +- Curtailment costs matter (oversizing wastes favorable scenarios) + +**Reality Check:** The actual sizing depends critically on: +- Electricity tariff structure and rates +- Technology capital costs +- Probability distribution of scenarios +- Financial parameters (discount rate, analysis period) + +### 2. **Expected Total Cost Relationship** + +**Key Relationship:** OUU expected cost ≥ Deterministic cost evaluated at expected case + +**Why:** The deterministic case optimizes for one specific scenario (the expected case), while OUU must perform well across ALL scenarios. This constraint typically increases cost. + +**Mathematical Insight:** +``` +Cost_OUU(optimal_sizing_for_all_scenarios) ≥ +Cost_Deterministic(optimal_sizing_for_expected_case, evaluated_at_expected_case) +``` + +But this doesn't mean OUU costs more in reality - it means: +1. **OUU accounts for risk** that deterministic ignores +2. **Deterministic case underestimates true expected cost** if uncertainty exists in reality +3. **OUU provides value through robustness** even if objective value is higher + +**The Robustness Premium:** +- OUU objective includes probability-weighted costs from all scenarios +- Deterministic objective only considers expected case +- The difference is the "cost of hedging" or "value of flexibility" +- May be 2-10% depending on uncertainty magnitude and cost structure + +**Important:** If real-world conditions vary but you sized deterministically, your **actual realized cost** could exceed OUU's expected cost because you're undersized for unfavorable scenarios. + +### 3. **More Conservative Dispatch** + +**Why:** System is sized to handle worst-case scenarios, leaving capacity margin in favorable scenarios. + +**Expected Observations:** +- **Lower capacity factors** in nominal and favorable scenarios +- **Reduced curtailment** in high-PV scenarios +- **Lower peak grid purchases** in high-load scenarios +- **Better resilience** (inherent to robust sizing) + +### 4. **Risk Reduction** + +**Why:** Robust sizing ensures the system performs acceptably even when reality differs from expectations. + +**Expected Observations:** +- **Lower variance** in annual costs across scenarios +- **Reduced worst-case costs** compared to deterministically-sized system facing uncertainty +- **Better peak load handling** without emergency grid purchases + +### 5. **Diminishing Returns with Larger Deviation** + +**Why:** As uncertainty increases, the optimizer must hedge more aggressively, increasing costs. + +**Expected Relationship:** +``` +Cost Increase ≈ f(deviation_fraction²) +``` + +At some point, it becomes uneconomical to hedge further, and grid purchases dominate. + +--- + +## Mathematical Formulation Details + +### Variable Structure + +**First-Stage (Sizing) Variables:** +```julia +dvSize[t] # Technology capacity (kW) +dvStoragePower[b] # Battery power capacity (kW) +dvStorageEnergy[b] # Battery energy capacity (kWh) +``` +*These variables are NOT scenario-indexed - one sizing decision for all scenarios.* + +**Second-Stage (Dispatch) Variables:** +```julia +dvRatedProduction[s, t, ts] # Production in scenario s, tech t, timestep ts +dvGridPurchase[s, ts, tier] # Grid purchase in scenario s +dvCurtail[s, t, ts] # Curtailment in scenario s +dvProductionToStorage[s, b, t, ts] # Charging in scenario s +dvDischargeFromStorage[s, b, ts] # Discharge in scenario s +dvStoredEnergy[s, b, ts] # State of charge in scenario s +dvPeakDemandMonth[s, mth, tier] # Monthly peak demand in scenario s +dvPeakDemandTOU[s, r, tier] # TOU peak demand in scenario s, ratchet r +``` +*All second-stage variables are scenario-indexed [s, ...] - different dispatch for each scenario.* + +**Note on Thermal Variables:** +- Thermal production variables (`dvHeatingProduction`, `dvCoolingProduction`) are NOT scenario-indexed +- Thermal storage state variables (`dvStoredEnergy` for thermal) ARE scenario-indexed +- This mixed approach reflects that thermal loads typically have less uncertainty than electric + +**Second-Stage (Binary) Variables:** +```julia +binMonthlyDemandTier[s, mth, tier] # Monthly demand tier selection per scenario +binTOUDemandTier[s, r, tier] # TOU demand tier selection per scenario +``` + +### Key Constraints + +**Production Limit (links first and second stage):** +```julia +@constraint(m, [s=1:n_scenarios, t in techs, ts in timesteps], + dvRatedProduction[s,t,ts] ≤ production_factor_by_scenario[s][t][ts] × dvSize[t] +) +``` + +**Load Balance (per scenario):** +```julia +@constraint(m, [s=1:n_scenarios, ts in timesteps], + sum(dvRatedProduction[s,t,ts] for t in techs) + + dvDischargeFromStorage[s,battery,ts] + + sum(dvGridPurchase[s,ts,tier] for tier in tiers) + == + loads_kw_by_scenario[s][ts] + + dvProductionToStorage[s,battery,tech,ts] + + dvCurtail[s,tech,ts] +) +``` + +**Storage SOC Evolution (per scenario):** +```julia +@constraint(m, [s=1:n_scenarios, ts=2:T], + dvStoredEnergy[s,b,ts] == + dvStoredEnergy[s,b,ts-1] + + η_charge × dvProductionToStorage[s,b,tech,ts] + - dvDischargeFromStorage[s,b,ts] / η_discharge +) +``` + +**Demand Charge Peak Tracking (per scenario):** +```julia +# Monthly peaks must exceed all grid purchases in that month +@constraint(m, [s=1:n_scenarios, mth in months, ts in month_timesteps[mth]], + dvPeakDemandMonth[s, mth] >= dvGridPurchase[s, ts] +) + +# TOU peaks must exceed grid purchases in ratchet period +@constraint(m, [s=1:n_scenarios, r in ratchets, ts in ratchet_timesteps[r]], + dvPeakDemandTOU[s, r] >= dvGridPurchase[s, ts] +) + +# Tier selection binaries (if tiered demand pricing) +@constraint(m, [s=1:n_scenarios, mth in months, tier in 1:n_tiers], + dvPeakDemandMonth[s, mth, tier] <= M * binMonthlyDemandTier[s, mth, tier] +) + +@constraint(m, [s=1:n_scenarios, mth in months, tier in 2:n_tiers], + binMonthlyDemandTier[s, mth, tier] <= binMonthlyDemandTier[s, mth, tier-1] +) +``` + +**Objective:** +```julia +# The objective in reopt.jl uses a unified Costs expression that automatically +# handles scenario aggregation through probability-weighted expressions + +@expression(m, Costs, + # First-Stage Costs (not scenario-indexed) + TotalTechCapCosts + # Capital costs for technologies + TotalStorageCapCosts + # Capital costs for storage (power + energy) + GHPCapCosts + # GHP capital costs + + # Fixed O&M (not scenario-indexed, tax deductible for owner) + (TotalPerUnitSizeOMCosts + GHPOMCosts + ElectricStorageOMCost) * + (1 - owner_tax_rate) + + + # Second-Stage Expected Costs (scenario probability-weighted internally) + TotalPerUnitProdOMCosts * (1 - owner_tax_rate) + # Variable O&M + TotalPerUnitHourOMCosts * (1 - owner_tax_rate) + # Hourly O&M + TotalFuelCosts * (1 - offtaker_tax_rate) + # Fuel costs + TotalCHPStandbyCharges * (1 - offtaker_tax_rate) + # CHP standby + TotalElecBill * (1 - offtaker_tax_rate) - # Utility bill + TotalProductionIncentive * (1 - owner_tax_rate) + # Production incentives + + # Additional costs and avoided costs + offgrid_other_costs + OffgridOtherCapexAfterDepr - + AvoidedCapexByGHP - ResidualGHXCapCost - AvoidedCapexByASHP +) + +# Where second-stage expressions internally use scenario probabilities: +TotalEnergyChargesUtil = pwf_e * hours_per_timestep * + sum(scenario_probabilities[s] * energy_rate[ts,tier] * dvGridPurchase[s,ts,tier] + for s in 1:n_scenarios, ts, tier) + +DemandTOUCharges = pwf_e * + sum(scenario_probabilities[s] * tou_rate[r,tier] * dvPeakDemandTOU[s,r,tier] + for s in 1:n_scenarios, r, tier) + +DemandFlatCharges = pwf_e * + sum(scenario_probabilities[s] * monthly_rate[mth,tier] * dvPeakDemandMonth[s,mth,tier] + for s in 1:n_scenarios, mth, tier) + +# Final objective +@objective(m, Min, Costs + ObjectivePenalties) +``` + +**Key Features:** +- **First-stage costs** (capital, fixed O&M) are NOT scenario-indexed - single decision for all scenarios +- **Second-stage costs** (energy, demand, fuel, variable O&M) ARE scenario probability-weighted +- Scenario probabilities are built into expressions like `TotalEnergyChargesUtil`, `DemandTOUCharges`, etc. +- Each scenario has its own peak demand: `E[DemandCost] = Σ prob[s] × rate[mth] × peak[s,mth]` +- Result is the expected total cost across all uncertainty scenarios +- Tax rates applied appropriately to owner vs. offtaker costs + +--- + +## Implementation Architecture + +### Data Flow + +``` +User JSON Input + ↓ +Scenario struct (uncertainty specs) + ↓ +REoptInputs (scenario generation) + ├─ generate_load_scenarios() → 3 load profiles + ├─ generate_production_scenarios() → 3 PV profiles + └─ combine_load_production_scenarios() → 9 joint scenarios + ↓ +JuMP Model Building + ├─ add_variables!() → scenario-indexed dispatch vars + ├─ add_constraints!() → scenario-aware constraints (iterator syntax) + └─ objective → first-stage + E[second-stage] + ↓ +Solver (HiGHS/Xpress) + ↓ +Results Processing + └─ Expected value dispatch profiles (probability-weighted) +``` + +### File Structure + +**Core Implementation:** +- `src/core/uncertainty.jl` - Scenario generation functions +- `src/core/scenario.jl` - Uncertainty parameter parsing +- `src/core/reopt_inputs.jl` - Scenario data storage, scenario_probabilities field +- `src/core/reopt.jl` - Variable creation with scenario indexing, unified objective + +**Constraint Files (all use iterator syntax for 5-15% faster model build):** +- `src/constraints/tech_constraints.jl` - Production constraints +- `src/constraints/storage_constraints.jl` - Storage dispatch (all loops converted) +- `src/constraints/electric_utility_constraints.jl` - Grid constraints, demand charges +- `src/constraints/load_balance.jl` - Load balance equations +- `src/constraints/generator_constraints.jl` - Generator constraints +- `src/constraints/renewable_energy_constraints.jl` - RE constraints with probability weighting +- `src/constraints/emissions_constraints.jl` - Emissions with probability weighting +- `src/constraints/production_incentive_constraints.jl` - Production incentives + +**Results Files (all compute expected values):** +- `src/results/electric_storage.jl` - SOC and discharge expected values +- `src/results/electric_tariff.jl` - Grid purchases, demand peaks expected values +- `src/results/electric_utility.jl` - Grid-to-load, grid-to-battery expected values +- `src/results/pv.jl` - PV dispatch expected values +- `src/results/generator.jl` - Generator dispatch expected values +- `src/results/chp.jl` - CHP dispatch expected values +- `src/results/wind.jl` - Wind dispatch expected values +- `src/results/steam_turbine.jl` - Steam turbine dispatch expected values + +**All results files use pattern:** +```julia +sum(p.scenario_probabilities[s] * value(m[:var][s,...]) for s in 1:p.n_scenarios) +``` + +### Performance Optimizations + +**Iterator Syntax in Constraints (5-15% faster model build):** +All constraint files have been converted from outer scenario loops to iterator syntax within `@constraint` macros: + +```julia +# Old approach (slower): +for s in 1:p.n_scenarios + @constraint(m, [ts in p.time_steps], + constraint_expression[s, ts] + ) +end + +# New approach (5-15% faster): +@constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + constraint_expression[s, ts] +) +``` + +**Total conversions:** 40+ outer scenario loops converted across: +- storage_constraints.jl (15 loops) +- electric_utility_constraints.jl (13 loops) +- load_balance.jl (7 loops) +- generator_constraints.jl (5 loops) +- tech_constraints.jl (2 loops) + +**Benchmark results:** Iterator approach is 5-15% faster for model building compared to outer loops, with the speedup increasing as the number of scenarios grows. + +--- + +## Current Implementation Status + +### Fully Operational Features + +**Core OUU Capabilities:** +- Electric load uncertainty +- PV production uncertainty +- Battery storage dispatch under uncertainty +- Grid purchases under uncertainty (including tiered energy pricing) +- Export revenues (scenario-indexed) +- Time-of-Use (TOU) pricing with scenario-specific dispatch +- Demand charges with probability-weighted expected value (scenario-indexed peaks and tiered pricing) +- Expected value results across all scenarios + +### Known Limitation: Outage Resilience with OUU + +**Current Behavior:** +When combining load/PV uncertainty with outage modeling, the outage constraints currently use the **nominal** (expected) critical load profile rather than selecting the worst-case scenario (high load + low PV). This is implemented in `src/constraints/outage_constraints.jl` line 5: + +```julia +p.s.electric_load.critical_loads_kw[time_step_wrap_around(...)] +``` + +**Impact:** +- System may be undersized for outage resilience if the actual load is higher than the nominal case +- Does not follow worst-case conservative planning approach typically desired for resilience +- `min_resil_time_steps` constraint applies to nominal load, not worst-case scenario + +**Recommended Fix:** +For conservative resilience planning when OUU is enabled, the outage constraints should use the worst-case scenario's critical load: + +```julia +# Identify worst-case scenario (highest load, lowest PV production) +worst_case_scenario_idx = identify_worst_case_scenario(p) + +# Use worst-case load in outage constraints +critical_load_for_outage = p.loads_kw_by_scenario[worst_case_scenario_idx][ts] +``` + +**Workaround:** +Until this is implemented, users can: +1. Run deterministic outage analysis separately with conservative load assumptions +2. Manually increase critical load values to represent worst-case conditions +3. Use `min_resil_time_steps` with conservative margins to ensure adequate sizing + +### Future Enhancement Opportunities + +**Thermal Systems:** +- Heating/cooling loads without uncertainty +- CHP, Boiler, ASHP, GHP dispatch deterministic + +**Advanced Results:** +- Per-scenario dispatch profiles +- Variability metrics (std dev, ranges) +- Risk metrics (CVaR, worst-case cost) +- Scenario comparison tables + +**Additional Renewables:** +- Wind production uncertainty (structure exists, needs testing) +- Multiple PV arrays with different uncertainty + +--- + +## Validation and Testing Strategy + +### Validation Tests Required + +See `test/test_ouu_foundation.jl` for comprehensive validation tests including: + +1. **Monotonicity Tests:** Larger uncertainty → larger sizing +2. **Boundary Tests:** Zero uncertainty should match deterministic +3. **Hedging Tests:** OUU sizing should exceed all individual scenarios +4. **Cost Tests:** OUU cost > deterministic cost (robust premium) +5. **Scenario Coverage:** All 9 scenarios properly generated +6. **Probability Validation:** Probabilities sum to 1.0 + +### Integration Testing + +Test interaction between: +- Different deviation fractions (5%, 10%, 20%) +- Different probability distributions (uniform, skewed) +- Multiple technologies under uncertainty +- Various site locations and load profiles + +--- + +## User Guide + +### Basic Usage + +```julia +using REopt, JuMP, HiGHS + +# Define scenario with uncertainty +scenario = Dict( + "ElectricLoad" => Dict( + "annual_kwh" => 1000000.0, + "uncertainty" => Dict( + "enabled" => true, + "deviation_fraction" => 0.1 + ) + ), + "PV" => Dict( + "max_kw" => 500.0, + "production_uncertainty" => Dict( + "enabled" => true, + "deviation_fraction" => 0.15 + ) + ), + "ElectricStorage" => Dict( + "max_kw" => 200.0, + "max_kwh" => 800.0 + ) +) + +# Build and solve +m = Model(HiGHS.Optimizer) +s = Scenario(scenario) +inputs = REoptInputs(s) +results = run_reopt(m, inputs) + +# Check scenarios generated +println("Number of scenarios: ", inputs.n_scenarios) # 9 +println("Scenario probabilities: ", inputs.scenario_probabilities) + +# Extract robust sizing decisions +println("Optimal PV size: ", results["PV"]["size_kw"], " kW") +println("Optimal battery: ", results["ElectricStorage"]["size_kw"], " kW") +``` + +### Comparing Deterministic vs. OUU + +```julia +# Run deterministic case +scenario_det = deepcopy(scenario) +scenario_det["ElectricLoad"]["uncertainty"]["enabled"] = false +scenario_det["PV"]["production_uncertainty"]["enabled"] = false + +m1 = Model(HiGHS.Optimizer) +results_det = run_reopt(m1, REoptInputs(Scenario(scenario_det))) + +# Run OUU case +m2 = Model(HiGHS.Optimizer) +results_ouu = run_reopt(m2, REoptInputs(Scenario(scenario))) + +# Compare +println("Deterministic PV: ", results_det["PV"]["size_kw"], " kW") +println("OUU PV: ", results_ouu["PV"]["size_kw"], " kW") +println("Sizing increase: ", + 100 * (results_ouu["PV"]["size_kw"] - results_det["PV"]["size_kw"]) / + results_det["PV"]["size_kw"], "%") +``` + +--- + +## References + +### REopt.jl Documentation +- [REopt.jl Docs](https://nrel.github.io/REopt.jl/stable/) +- [GitHub Repository](https://github.com/NREL/REopt.jl) + +--- + +## Conclusion + +The OUU implementation enables REopt.jl to make robust technology investment decisions that account for uncertainty in load demand and renewable production. By using two-stage stochastic programming, the model finds optimal sizing that minimizes expected costs across all possible future scenarios, providing users with systems that are resilient to variability in demand and renewable output. + +**Key Takeaways:** +1. **OUU finds economically optimal sizing** - not necessarily larger or smaller, but right-sized for uncertainty +2. **The objective accounts for all scenarios** - providing a more complete picture of expected costs +3. **Value comes from robustness** - systems perform well even when conditions differ from expectations +4. **Trade-offs are explicit** - balances capital costs against expected operating costs across scenarios +5. **Better decision-making** - incorporates risk and uncertainty that deterministic models ignore diff --git a/src/REopt.jl b/src/REopt.jl index 000e29aa4..38c9d3fe1 100644 --- a/src/REopt.jl +++ b/src/REopt.jl @@ -145,6 +145,7 @@ include("core/electric_heater.jl") include("core/cst_ssc.jl") include("core/cst.jl") include("core/ashp.jl") +include("core/uncertainty.jl") include("core/scenario.jl") include("core/bau_scenario.jl") include("core/reopt_inputs.jl") diff --git a/src/constraints/battery_degradation.jl b/src/constraints/battery_degradation.jl index dda3cb797..efcc0858b 100644 --- a/src/constraints/battery_degradation.jl +++ b/src/constraints/battery_degradation.jl @@ -29,7 +29,7 @@ function constrain_degradation_variables(m, p; b="ElectricStorage") end @constraint(m, [d in days], - m[:Eavg][d] == 1/ts_per_day * sum(m[:dvStoredEnergy][b, ts] for ts in ts0[d]:tsF[d]) + m[:Eavg][d] == 1/ts_per_day * sum(sum(p.scenario_probabilities[s] * m[:dvStoredEnergy][s, b, ts] for s in 1:p.n_scenarios) for ts in ts0[d]:tsF[d]) ) @constraint(m, [d in days], @@ -38,13 +38,13 @@ function constrain_degradation_variables(m, p; b="ElectricStorage") # Power in equals power into storage from grid or local production @constraint(m, [ts in p.time_steps], - sum(m[:dvSegmentChargePower][ts, j] for j in 1:J) == sum( - m[:dvProductionToStorage][b, t, ts] for t in p.techs.elec) + m[:dvGridToStorage][b, ts] + sum(m[:dvSegmentChargePower][ts, j] for j in 1:J) == sum(p.scenario_probabilities[s] * (sum( + m[:dvProductionToStorage][s, b, t, ts] for t in p.techs.elec) + m[:dvGridToStorage][s, b, ts]) for s in 1:p.n_scenarios) ) # Power out equals power discharged from storage to any destination @constraint(m, [ts in p.time_steps], - sum(m[:dvSegmentDischargePower][ts, j] for j in 1:J) == m[:dvDischargeFromStorage][b, ts]); + sum(m[:dvSegmentDischargePower][ts, j] for j in 1:J) == sum(p.scenario_probabilities[s] * m[:dvDischargeFromStorage][s, b, ts] for s in 1:p.n_scenarios)); # Balance charging with daily e_plus, here is only collect all power across the day, so don't need to times efficiency @constraint(m, [d in days, j in 1:J], m[:Eplus_sum][d, j] == sum(m[:dvSegmentChargePower][ts0[d]:tsF[d], j])*p.hours_per_time_step) diff --git a/src/constraints/chp_constraints.jl b/src/constraints/chp_constraints.jl index 0f25bf54f..79c85b06b 100644 --- a/src/constraints/chp_constraints.jl +++ b/src/constraints/chp_constraints.jl @@ -9,7 +9,8 @@ function add_chp_fuel_burn_constraints(m, p; _n="") # Fuel cost m[:TotalCHPFuelCosts] = @expression(m, - sum(p.pwf_fuel[t] * m[:dvFuelUsage][t, ts] * p.fuel_cost_per_kwh[t][ts] for t in p.techs.chp, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.pwf_fuel[t] * m[:dvFuelUsage][s, t, ts] * p.fuel_cost_per_kwh[t][ts] + for s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps) ) # Conditionally add dvFuelBurnYIntercept if coefficient p.FuelBurnYIntRate is greater than ~zero if abs(fuel_burn_intercept) > 1.0E-7 @@ -17,24 +18,24 @@ function add_chp_fuel_burn_constraints(m, p; _n="") m[Symbol(dv)] = @variable(m, [p.techs.chp, p.time_steps], base_name=dv) #Constraint (1c1): Total Fuel burn for CHP **with** y-intercept fuel burn and supplementary firing - @constraint(m, CHPFuelBurnCon[t in p.techs.chp, ts in p.time_steps], - m[Symbol("dvFuelUsage"*_n)][t,ts] == p.hours_per_time_step * ( + @constraint(m, CHPFuelBurnCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvFuelUsage"*_n)][s,t,ts] == p.hours_per_time_step * ( m[Symbol("dvFuelBurnYIntercept"*_n)][t,ts] + - p.production_factor[t,ts] * fuel_burn_slope * m[Symbol("dvRatedProduction"*_n)][t,ts] + - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] / p.s.chp.supplementary_firing_efficiency + p.production_factor_by_scenario[s][t][ts] * fuel_burn_slope * m[Symbol("dvRatedProduction"*_n)][s,t,ts] + + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] / p.s.chp.supplementary_firing_efficiency ) ) #Constraint (1d): Y-intercept fuel burn for CHP - @constraint(m, CHPFuelBurnYIntCon[t in p.techs.chp, ts in p.time_steps], + @constraint(m, CHPFuelBurnYIntCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], fuel_burn_intercept * m[Symbol("dvSize"*_n)][t] - p.s.chp.max_kw * - (1-m[Symbol("binCHPIsOnInTS"*_n)][t,ts]) <= m[Symbol("dvFuelBurnYIntercept"*_n)][t,ts] + (1-m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts]) <= m[Symbol("dvFuelBurnYIntercept"*_n)][t,ts] ) else #Constraint (1c2): Total Fuel burn for CHP **without** y-intercept fuel burn - @constraint(m, CHPFuelBurnConLinear[t in p.techs.chp, ts in p.time_steps], - m[Symbol("dvFuelUsage"*_n)][t,ts] == p.hours_per_time_step * ( - p.production_factor[t,ts] * fuel_burn_slope * m[Symbol("dvRatedProduction"*_n)][t,ts] + - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] / p.s.chp.supplementary_firing_efficiency + @constraint(m, CHPFuelBurnConLinear[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvFuelUsage"*_n)][s,t,ts] == p.hours_per_time_step * ( + p.production_factor_by_scenario[s][t][ts] * fuel_burn_slope * m[Symbol("dvRatedProduction"*_n)][s,t,ts] + + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] / p.s.chp.supplementary_firing_efficiency ) ) end @@ -58,28 +59,28 @@ function add_chp_thermal_production_constraints(m, p; _n="") m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts] <= thermal_prod_intercept * m[Symbol("dvSize"*_n)][t] ) # Constraint (2a-2): Upper Bounds on Thermal Production Y-Intercept - @constraint(m, CHPYInt2a2Con[t in p.techs.chp, ts in p.time_steps], + @constraint(m, CHPYInt2a2Con[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts] <= thermal_prod_intercept * p.s.chp.max_kw - * m[Symbol("binCHPIsOnInTS"*_n)][t,ts] + * m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts] ) #Constraint (2b): Lower Bounds on Thermal Production Y-Intercept - @constraint(m, CHPYInt2bCon[t in p.techs.chp, ts in p.time_steps], + @constraint(m, CHPYInt2bCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts] >= thermal_prod_intercept * m[Symbol("dvSize"*_n)][t] - - thermal_prod_intercept * p.s.chp.max_kw * (1 - m[Symbol("binCHPIsOnInTS"*_n)][t,ts]) + - thermal_prod_intercept * p.s.chp.max_kw * (1 - m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts]) ) # Constraint (2c): Thermal Production of CHP # Note: p.HotWaterAmbientFactor[t,ts] * p.HotWaterThermalFactor[t,ts] removed from this but present in math - @constraint(m, CHPThermalProductionCon[t in p.techs.chp, ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) == - thermal_prod_slope * p.production_factor[t,ts] * m[Symbol("dvRatedProduction"*_n)][t,ts] + @constraint(m, CHPThermalProductionCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) == + thermal_prod_slope * p.production_factor_by_scenario[s][t][ts] * m[Symbol("dvRatedProduction"*_n)][s,t,ts] + m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts] + - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] ) else - @constraint(m, CHPThermalProductionConLinear[t in p.techs.chp, ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) == - thermal_prod_slope * p.production_factor[t,ts] * m[Symbol("dvRatedProduction"*_n)][t,ts] + - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] + @constraint(m, CHPThermalProductionConLinear[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) == + thermal_prod_slope * p.production_factor_by_scenario[s][t][ts] * m[Symbol("dvRatedProduction"*_n)][s,t,ts] + + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] ) end @@ -98,39 +99,39 @@ function add_chp_supplementary_firing_constraints(m, p; _n="") thermal_prod_slope = (thermal_prod_full_load - thermal_prod_half_load) / (1.0 - 0.5) # [kWt/kWe] # Constrain upper limit of dvSupplementaryThermalProduction, using auxiliary variable for (size * useSupplementaryFiring) - @constraint(m, CHPSupplementaryFireCon[t in p.techs.chp, ts in p.time_steps], - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] <= - (p.s.chp.supplementary_firing_max_steam_ratio - 1.0) * p.production_factor[t,ts] * (thermal_prod_slope * m[Symbol("dvSupplementaryFiringSize"*_n)][t] + m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts]) + @constraint(m, CHPSupplementaryFireCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] <= + (p.s.chp.supplementary_firing_max_steam_ratio - 1.0) * p.production_factor_by_scenario[s][t][ts] * (thermal_prod_slope * m[Symbol("dvSupplementaryFiringSize"*_n)][t] + m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts]) ) if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) # Constrain lower limit of 0 if CHP tech is off - @constraint(m, NoCHPSupplementaryFireOffCon[t in p.techs.chp, ts in p.time_steps], - !m[Symbol("binCHPIsOnInTS"*_n)][t,ts] => {m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] <= 0.0} + @constraint(m, NoCHPSupplementaryFireOffCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + !m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts] => {m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] <= 0.0} ) else #There's no upper bound specified for the CHP supplementary firing, so assume the entire heat load as a reasonable maximum that wouldn't be exceeded (but might not be the best possible value). max_supplementary_firing_size = maximum(p.s.dhw_load.loads_kw .+ p.s.space_heating_load.loads_kw) - @constraint(m, NoCHPSupplementaryFireOffCon[t in p.techs.chp, ts in p.time_steps], - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] <= (p.s.chp.supplementary_firing_max_steam_ratio - 1.0) * p.production_factor[t,ts] * (thermal_prod_slope * max_supplementary_firing_size + m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts]) + @constraint(m, NoCHPSupplementaryFireOffCon[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts] <= (p.s.chp.supplementary_firing_max_steam_ratio - 1.0) * p.production_factor_by_scenario[s][t][ts] * (thermal_prod_slope * max_supplementary_firing_size + m[Symbol("dvHeatingProductionYIntercept"*_n)][t,ts]) ) end end function add_binCHPIsOnInTS_constraints(m, p; _n="") # Note, min_turn_down_fraction for CHP is only enforced in p.time_steps_with_grid - @constraint(m, [t in p.techs.chp, ts in p.time_steps_with_grid], - m[Symbol("dvRatedProduction"*_n)][t, ts] <= p.s.chp.max_kw * m[Symbol("binCHPIsOnInTS"*_n)][t, ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps_with_grid], + m[Symbol("dvRatedProduction"*_n)][s, t, ts] <= p.s.chp.max_kw * m[Symbol("binCHPIsOnInTS"*_n)][s, t, ts] ) - @constraint(m, [t in p.techs.chp, ts in p.time_steps_with_grid], - p.s.chp.min_turn_down_fraction * m[Symbol("dvSize"*_n)][t] - m[Symbol("dvRatedProduction"*_n)][t, ts] <= - p.s.chp.max_kw * (1 - m[Symbol("binCHPIsOnInTS"*_n)][t, ts]) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps_with_grid], + p.s.chp.min_turn_down_fraction * m[Symbol("dvSize"*_n)][t] - m[Symbol("dvRatedProduction"*_n)][s, t, ts] <= + p.s.chp.max_kw * (1 - m[Symbol("binCHPIsOnInTS"*_n)][s, t, ts]) ) end function add_chp_rated_prod_constraint(m, p; _n="") - @constraint(m, [t in p.techs.chp, ts in p.time_steps], - m[Symbol("dvSize"*_n)][t] >= m[Symbol("dvRatedProduction"*_n)][t, ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvSize"*_n)][t] >= m[Symbol("dvRatedProduction"*_n)][s, t, ts] ) end @@ -146,9 +147,9 @@ function add_chp_hourly_om_charges(m, p; _n="") m[Symbol(dv)] = @variable(m, [p.techs.chp, p.time_steps], base_name=dv, lower_bound=0) #Constraint CHP-hourly-om-a: om per hour, per time step >= per_unit_size_cost * size for when on, >= zero when off - @constraint(m, CHPHourlyOMBySizeA[t in p.techs.chp, ts in p.time_steps], + @constraint(m, CHPHourlyOMBySizeA[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], p.s.chp.om_cost_per_hr_per_kw_rated * m[Symbol("dvSize"*_n)][t] - - p.s.chp.max_kw * p.s.chp.om_cost_per_hr_per_kw_rated * (1-m[Symbol("binCHPIsOnInTS"*_n)][t,ts]) + p.s.chp.max_kw * p.s.chp.om_cost_per_hr_per_kw_rated * (1-m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts]) <= m[Symbol("dvOMByHourBySizeCHP"*_n)][t, ts] ) #Constraint CHP-hourly-om-b: om per hour, per time step <= per_unit_size_cost * size for each hour @@ -157,8 +158,8 @@ function add_chp_hourly_om_charges(m, p; _n="") >= m[Symbol("dvOMByHourBySizeCHP"*_n)][t, ts] ) #Constraint CHP-hourly-om-c: om per hour, per time step <= zero when off, <= per_unit_size_cost*max_size - @constraint(m, CHPHourlyOMBySizeC[t in p.techs.chp, ts in p.time_steps], - p.s.chp.max_kw * p.s.chp.om_cost_per_hr_per_kw_rated * m[Symbol("binCHPIsOnInTS"*_n)][t,ts] + @constraint(m, CHPHourlyOMBySizeC[s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps], + p.s.chp.max_kw * p.s.chp.om_cost_per_hr_per_kw_rated * m[Symbol("binCHPIsOnInTS"*_n)][s,t,ts] >= m[Symbol("dvOMByHourBySizeCHP"*_n)][t, ts] ) @@ -179,14 +180,14 @@ function add_chp_constraints(m, p; _n="") @warn """Adding binary variable to model CHP. Some solvers are very slow with integer variables""" @variables m begin - binCHPIsOnInTS[p.techs.chp, p.time_steps], Bin # 1 If technology t is operating in time step; 0 otherwise + binCHPIsOnInTS[1:p.n_scenarios, p.techs.chp, p.time_steps], Bin # 1 If technology t is operating in time step in scenario s; 0 otherwise end m[:TotalHourlyCHPOMCosts] = 0 m[:TotalCHPFuelCosts] = 0 m[:TotalCHPPerUnitProdOMCosts] = @expression(m, p.third_party_factor * p.pwf_om * - sum(p.s.chp.om_cost_per_kwh * p.hours_per_time_step * - m[:dvRatedProduction][t, ts] for t in p.techs.chp, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.s.chp.om_cost_per_kwh * p.hours_per_time_step * + m[:dvRatedProduction][s, t, ts] for s in 1:p.n_scenarios, t in p.techs.chp, ts in p.time_steps) ) if p.s.chp.om_cost_per_hr_per_kw_rated > 1.0E-7 @@ -203,8 +204,8 @@ function add_chp_constraints(m, p; _n="") else for t in p.techs.chp fix(m[Symbol("dvSupplementaryFiringSize"*_n)][t], 0.0, force=true) - for ts in p.time_steps - fix(m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts], 0.0, force=true) end end end diff --git a/src/constraints/electric_utility_constraints.jl b/src/constraints/electric_utility_constraints.jl index 07dbbab76..88682618c 100644 --- a/src/constraints/electric_utility_constraints.jl +++ b/src/constraints/electric_utility_constraints.jl @@ -2,17 +2,17 @@ function add_export_constraints(m, p; _n="") ## Imports and Exports must be no greater than the transmission limit - m[Symbol("ImportExportLimitCon"*_n)] = @constraint(m, [ts in p.time_steps_with_grid], - sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) - + sum(sum( m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers)) + m[Symbol("ImportExportLimitCon"*_n)] = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + sum(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) + + sum(sum( m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers)) <= p.s.electric_utility.transmission_limit_kw ) ##Constraint (8e): Production export and curtailment no greater than production - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - p.production_factor[t,ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] - >= sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t]) + - m[Symbol("dvCurtail"*_n)][t, ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] + >= sum(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts] for u in p.export_bins_by_tech[t]) + + m[Symbol("dvCurtail"*_n)][s, t, ts] ) binNEM = 0 @@ -26,10 +26,10 @@ function add_export_constraints(m, p; _n="") if !isempty(NEM_techs) # Constraint (9c): Net metering only -- can't sell more than you purchase # hours_per_time_step is cancelled on both sides, but used for unit consistency (convert power to energy) - @constraint(m, - p.hours_per_time_step * sum( m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] + @constraint(m, [s in 1:p.n_scenarios], + p.hours_per_time_step * sum( m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] for t in NEM_techs, ts in p.time_steps) - <= p.hours_per_time_step * sum( m[Symbol("dvGridPurchase"*_n)][ts, tier] + <= p.hours_per_time_step * sum( m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) ) @@ -40,13 +40,13 @@ function add_export_constraints(m, p; _n="") sum(m[Symbol("dvSize"*_n)][t] for t in NEM_techs) <= p.s.electric_utility.interconnection_limit_kw ) NEM_benefit = @expression(m, p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] - for t in p.techs_by_exportbin[:NEM]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] + for t in p.techs_by_exportbin[:NEM]) for s in 1:p.n_scenarios, ts in p.time_steps) ) if :EXC in p.s.electric_tariff.export_bins EXC_benefit = @expression(m, p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :EXC, ts] - for t in p.techs_by_exportbin[:EXC]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :EXC, ts] + for t in p.techs_by_exportbin[:EXC]) for s in 1:p.n_scenarios, ts in p.time_steps) ) end else @@ -93,24 +93,24 @@ function add_export_constraints(m, p; _n="") if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) @constraint(m, binNEM => {NEM_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] - for t in p.techs_by_exportbin[:NEM]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] + for t in p.techs_by_exportbin[:NEM]) for s in 1:p.n_scenarios, ts in p.time_steps) } ) @constraint(m, !binNEM => {NEM_benefit >= 0}) - @constraint(m,[ts in p.time_steps_with_grid, t in p.techs_by_exportbin[:NEM]], - !binNEM => { m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] == 0 + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid, t in p.techs_by_exportbin[:NEM]], + !binNEM => { m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] == 0 } ) else @constraint(m, NEM_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] - for t in p.techs_by_exportbin[:NEM]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:NEM][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] + for t in p.techs_by_exportbin[:NEM]) for s in 1:p.n_scenarios, ts in p.time_steps) ) @constraint(m, NEM_benefit >= max_bene * binNEM) - @constraint(m,[ts in p.time_steps_with_grid, t in p.techs_by_exportbin[:NEM]], - m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] <= binNEM * sum(p.s.electric_load.loads_kw) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid, t in p.techs_by_exportbin[:NEM]], + m[Symbol("dvProductionToGrid"*_n)][s, t, :NEM, ts] <= binNEM * sum(p.s.electric_load.loads_kw) ) end @@ -120,16 +120,16 @@ function add_export_constraints(m, p; _n="") if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) @constraint(m, binNEM => {EXC_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :EXC, ts] - for t in p.techs_by_exportbin[:EXC]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :EXC, ts] + for t in p.techs_by_exportbin[:EXC]) for s in 1:p.n_scenarios, ts in p.time_steps) } ) @constraint(m, !binNEM => {EXC_benefit >= 0}) else @constraint(m, EXC_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :EXC, ts] - for t in p.techs_by_exportbin[:EXC]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:EXC][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :EXC, ts] + for t in p.techs_by_exportbin[:EXC]) for s in 1:p.n_scenarios, ts in p.time_steps) ) @constraint(m, EXC_benefit >= max_bene * binNEM) end @@ -142,8 +142,8 @@ function add_export_constraints(m, p; _n="") if typeof(binNEM) <: Real # no need for wholesale binary binWHL = 1 WHL_benefit = @expression(m, p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :WHL, ts] - for t in p.techs_by_exportbin[:WHL]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :WHL, ts] + for t in p.techs_by_exportbin[:WHL]) for s in 1:p.n_scenarios, ts in p.time_steps) ) else binWHL = @variable(m, binary = true) @@ -155,16 +155,16 @@ function add_export_constraints(m, p; _n="") if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) @constraint(m, binWHL => {WHL_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :WHL, ts] - for t in p.techs_by_exportbin[:WHL]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :WHL, ts] + for t in p.techs_by_exportbin[:WHL]) for s in 1:p.n_scenarios, ts in p.time_steps) } ) @constraint(m, !binWHL => {WHL_benefit >= 0}) else @constraint(m, WHL_benefit >= p.pwf_e * p.hours_per_time_step * - sum( sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][t, :WHL, ts] - for t in p.techs_by_exportbin[:WHL]) for ts in p.time_steps) + sum( p.scenario_probabilities[s] * sum(p.s.electric_tariff.export_rates[:WHL][ts] * m[Symbol("dvProductionToGrid"*_n)][s, t, :WHL, ts] + for t in p.techs_by_exportbin[:WHL]) for s in 1:p.n_scenarios, ts in p.time_steps) ) @constraint(m, WHL_benefit >= max_bene * binWHL) end @@ -190,18 +190,18 @@ function add_monthly_peak_constraint(m, p; _n="") ## Constraint (11d): Monthly peak demand is >= demand at each hour in the month if (!isempty(p.techs.chp)) && !(p.s.chp.reduces_demand_charges) - @constraint(m, [mth in p.months, ts in p.s.electric_tariff.time_steps_monthly[mth]], - sum(m[Symbol("dvPeakDemandMonth"*_n)][mth, t] for t in 1:p.s.electric_tariff.n_monthly_demand_tiers) - >= sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + - sum(p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t, ts] for t in p.techs.chp) - - sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) for t in p.techs.chp) - - sum(sum(m[Symbol("dvProductionToGrid")][t,u,ts] for u in p.export_bins_by_tech[t]) for t in p.techs.chp) + @constraint(m, [s in 1:p.n_scenarios, mth in p.months, ts in p.s.electric_tariff.time_steps_monthly[mth]], + sum(m[Symbol("dvPeakDemandMonth"*_n)][s, mth, t] for t in 1:p.s.electric_tariff.n_monthly_demand_tiers) + >= sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + + sum(p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t, ts] for t in p.techs.chp) - + sum(sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) for t in p.techs.chp) - + sum(sum(m[Symbol("dvProductionToGrid")][s, t,u,ts] for u in p.export_bins_by_tech[t]) for t in p.techs.chp) ) else - @constraint(m, [mth in p.months, ts in p.s.electric_tariff.time_steps_monthly[mth]], - sum(m[Symbol("dvPeakDemandMonth"*_n)][mth, t] for t in 1:p.s.electric_tariff.n_monthly_demand_tiers) - >= sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + @constraint(m, [s in 1:p.n_scenarios, mth in p.months, ts in p.s.electric_tariff.time_steps_monthly[mth]], + sum(m[Symbol("dvPeakDemandMonth"*_n)][s, mth, t] for t in 1:p.s.electric_tariff.n_monthly_demand_tiers) + >= sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) ) end @@ -215,19 +215,21 @@ function add_monthly_peak_constraint(m, p; _n="") bigM_monthly_demand_tier_limits = get_electric_demand_tiers_bigM(p, false) # Upper bound on peak electrical power demand by month, tier; if tier is selected (0 o.w.) - @constraint(m, [mth in p.months, tier in 1:ntiers], - m[Symbol("dvPeakDemandMonth"*_n)][mth, tier] <= bigM_monthly_demand_tier_limits[mth, tier] * - b[mth, tier] - ) + for s in 1:p.n_scenarios + @constraint(m, [mth in p.months, tier in 1:ntiers], + m[Symbol("dvPeakDemandMonth"*_n)][s, mth, tier] <= bigM_monthly_demand_tier_limits[mth, tier] * + b[mth, tier] + ) + + # One monthly peak electrical power demand tier must be full before next one is active + @constraint(m, [mth in p.months, tier in 2:ntiers], + b[mth, tier] * bigM_monthly_demand_tier_limits[mth, tier-1] <= + m[Symbol("dvPeakDemandMonth"*_n)][s, mth, tier-1] + ) + end # Monthly peak electrical power demand tier ordering @constraint(m, [mth in p.months, tier in 2:ntiers], b[mth, tier] <= b[mth, tier-1]) - - # One monthly peak electrical power demand tier must be full before next one is active - @constraint(m, [mth in p.months, tier in 2:ntiers], - b[mth, tier] * bigM_monthly_demand_tier_limits[mth, tier-1] <= - m[Symbol("dvPeakDemandMonth"*_n)][mth, tier-1] - ) # TODO implement NewMaxDemandMonthsInTier, which adds mth index to monthly_demand_tier_limits end end @@ -235,10 +237,12 @@ end function add_tou_peak_constraint(m, p; _n="") ## Constraint (12d): Ratchet peak demand is >= demand at each hour in the ratchet` - @constraint(m, [r in p.ratchets, ts in p.s.electric_tariff.tou_demand_ratchet_time_steps[r]], - sum(m[Symbol("dvPeakDemandTOU"*_n)][r, tier] for tier in 1:p.s.electric_tariff.n_tou_demand_tiers) >= - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - ) + for s in 1:p.n_scenarios + @constraint(m, [r in p.ratchets, ts in p.s.electric_tariff.tou_demand_ratchet_time_steps[r]], + sum(m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier] for tier in 1:p.s.electric_tariff.n_tou_demand_tiers) >= + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + ) + end if p.s.electric_tariff.n_tou_demand_tiers > 1 @warn "Adding binary variables to model TOU demand tiers." @@ -250,19 +254,19 @@ function add_tou_peak_constraint(m, p; _n="") bigM_tou_demand_tier_limits = get_electric_demand_tiers_bigM(p, true) # Upper bound on peak electrical power demand by tier, by ratchet, if tier is selected (0 o.w.) - @constraint(m, [r in p.ratchets, tier in 1:ntiers], - m[Symbol("dvPeakDemandTOU"*_n)][r, tier] <= bigM_tou_demand_tier_limits[r, tier] * b[r, tier] + @constraint(m, [s in 1:p.n_scenarios, r in p.ratchets, tier in 1:ntiers], + m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier] <= bigM_tou_demand_tier_limits[r, tier] * b[r, tier] ) - # Ratchet peak electrical power ratchet tier ordering - @constraint(m, [r in p.ratchets, tier in 2:ntiers], - b[r, tier] <= b[r, tier-1] + # One ratchet peak electrical power demand tier must be full before next one is active + @constraint(m, [s in 1:p.n_scenarios, r in p.ratchets, tier in 2:ntiers], + b[r, tier] * bigM_tou_demand_tier_limits[r, tier-1] + <= m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier-1] ) - # One ratchet peak electrical power demand tier must be full before next one is active + # Ratchet peak electrical power ratchet tier ordering @constraint(m, [r in p.ratchets, tier in 2:ntiers], - b[r, tier] * bigM_tou_demand_tier_limits[r, tier-1] - <= m[Symbol("dvPeakDemandTOU"*_n)][r, tier-1] + b[r, tier] <= b[r, tier-1] ) end # TODO implement NewMaxDemandInTier @@ -279,25 +283,25 @@ end function add_simultaneous_export_import_constraint(m, p; _n="") if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) - @constraint(m, NoGridPurchasesBinary[ts in p.time_steps], - m[Symbol("binNoGridPurchases"*_n)][ts] => { - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) <= 0 + @constraint(m, NoGridPurchasesBinary[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("binNoGridPurchases"*_n)][s, ts] => { + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + + sum(m[Symbol("dvGridToStorage"*_n)][s, b, ts] for b in p.s.storage.types.elec) <= 0 } ) - @constraint(m, ExportOnlyAfterSiteLoadMetCon[ts in p.time_steps], - !m[Symbol("binNoGridPurchases"*_n)][ts] => { - sum(m[Symbol("dvProductionToGrid"*_n)][t,u,ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) <= 0 + @constraint(m, ExportOnlyAfterSiteLoadMetCon[s in 1:p.n_scenarios, ts in p.time_steps], + !m[Symbol("binNoGridPurchases"*_n)][s, ts] => { + sum(m[Symbol("dvProductionToGrid"*_n)][s, t,u,ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) <= 0 } ) else bigM_hourly_load = maximum(p.s.electric_load.loads_kw)+maximum(p.s.space_heating_load.loads_kw)+maximum(p.s.process_heat_load.loads_kw)+maximum(p.s.dhw_load.loads_kw)+maximum(p.s.cooling_load.loads_kw_thermal) - @constraint(m, NoGridPurchasesBinary[ts in p.time_steps], - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) <= bigM_hourly_load*(1-m[Symbol("binNoGridPurchases"*_n)][ts]) + @constraint(m, NoGridPurchasesBinary[s in 1:p.n_scenarios, ts in p.time_steps], + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + + sum(m[Symbol("dvGridToStorage"*_n)][s, b, ts] for b in p.s.storage.types.elec) <= bigM_hourly_load*(1-m[Symbol("binNoGridPurchases"*_n)][s, ts]) ) - @constraint(m, ExportOnlyAfterSiteLoadMetCon[ts in p.time_steps], - sum(m[Symbol("dvProductionToGrid"*_n)][t,u,ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) <= bigM_hourly_load * m[Symbol("binNoGridPurchases"*_n)][ts] + @constraint(m, ExportOnlyAfterSiteLoadMetCon[s in 1:p.n_scenarios, ts in p.time_steps], + sum(m[Symbol("dvProductionToGrid"*_n)][s, t,u,ts] for t in p.techs.elec, u in p.export_bins_by_tech[t]) <= bigM_hourly_load * m[Symbol("binNoGridPurchases"*_n)][s, ts] ) end end @@ -318,20 +322,22 @@ function add_energy_tier_constraints(m, p; _n="") bigM_energy_tier_limits = get_electric_energy_tiers_bigM(p) ##Constraint (10a): Usage limits by pricing tier, by month - @constraint(m, [mth in p.months, tier in 1:p.s.electric_tariff.n_energy_tiers], - p.hours_per_time_step * sum( m[Symbol("dvGridPurchase"*_n)][ts, tier] for ts in p.s.electric_tariff.time_steps_monthly[mth] ) - <= b[mth, tier] * bigM_energy_tier_limits[mth, tier] - ) + for s in 1:p.n_scenarios + @constraint(m, [mth in p.months, tier in 1:p.s.electric_tariff.n_energy_tiers], + p.hours_per_time_step * sum( m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for ts in p.s.electric_tariff.time_steps_monthly[mth] ) + <= b[mth, tier] * bigM_energy_tier_limits[mth, tier] + ) + ## Constraint (10c): One tier must be full before any usage in next tier + @constraint(m, [mth in p.months, tier in 2:p.s.electric_tariff.n_energy_tiers], + b[mth, tier] * bigM_energy_tier_limits[mth, tier-1] - + sum( m[Symbol("dvGridPurchase"*_n)][s, ts, tier-1] for ts in p.s.electric_tariff.time_steps_monthly[mth]) + <= 0 + ) + end ##Constraint (10b): Ordering of pricing tiers @constraint(m, [mth in p.months, tier in 2:p.s.electric_tariff.n_energy_tiers], b[mth, tier] - b[mth, tier-1] <= 0 ) - ## Constraint (10c): One tier must be full before any usage in next tier - @constraint(m, [mth in p.months, tier in 2:p.s.electric_tariff.n_energy_tiers], - b[mth, tier] * bigM_energy_tier_limits[mth, tier-1] - - sum( m[Symbol("dvGridPurchase"*_n)][ts, tier-1] for ts in p.s.electric_tariff.time_steps_monthly[mth]) - <= 0 - ) # TODO implement NewMaxUsageInTier end @@ -348,29 +354,28 @@ function add_demand_lookback_constraints(m, p; _n="") if p.s.electric_tariff.demand_lookback_range != 0 # then the dvPeakDemandLookback varies by month ##Constraint (12e): dvPeakDemandLookback is the highest peak demand in DemandLookbackMonths - @constraint(m, [mth in p.months, lm in 1:p.s.electric_tariff.demand_lookback_range, ts in p.s.electric_tariff.time_steps_monthly[mod(mth - lm - 1, 12) + 1]], - m[Symbol(dv)][mth] ≥ sum( m[Symbol("dvGridPurchase"*_n)][ts, tier] + @constraint(m, [s in 1:p.n_scenarios, mth in p.months, lm in 1:p.s.electric_tariff.demand_lookback_range, ts in p.s.electric_tariff.time_steps_monthly[mod(mth - lm - 1, 12) + 1]], + m[Symbol(dv)][mth] ≥ sum( m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers ) ) - ##Constraint (12f): Ratchet peak demand charge is bounded below by lookback - @constraint(m, [mth in p.months], - sum( m[Symbol("dvPeakDemandMonth"*_n)][mth, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers ) >= - p.s.electric_tariff.demand_lookback_percent * m[Symbol(dv)][mth] - ) - + ##Constraint (12f): Ratchet peak demand charge is bounded below by lookback + @constraint(m, [s in 1:p.n_scenarios, mth in p.months], + sum( m[Symbol("dvPeakDemandMonth"*_n)][s, mth, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers ) >= + p.s.electric_tariff.demand_lookback_percent * m[Symbol(dv)][mth] + ) else # dvPeakDemandLookback does not vary by month ##Constraint (12e): dvPeakDemandLookback is the highest peak demand in demand_lookback_months - @constraint(m, [lm in p.s.electric_tariff.demand_lookback_months], - m[Symbol(dv)][1] >= sum(m[Symbol("dvPeakDemandMonth"*_n)][lm, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers) - ) - - ##Constraint (12f): Ratchet peak demand charge is bounded below by lookback - @constraint(m, [mth in p.months], - sum( m[Symbol("dvPeakDemandMonth"*_n)][mth, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers ) >= - p.s.electric_tariff.demand_lookback_percent * m[Symbol(dv)][1] - ) + @constraint(m, [s in 1:p.n_scenarios, lm in p.s.electric_tariff.demand_lookback_months], + m[Symbol(dv)][1] >= sum(m[Symbol("dvPeakDemandMonth"*_n)][s, lm, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers) + ) + + ##Constraint (12f): Ratchet peak demand charge is bounded below by lookback + @constraint(m, [s in 1:p.n_scenarios, mth in p.months], + sum( m[Symbol("dvPeakDemandMonth"*_n)][s, mth, tier] for tier in 1:p.s.electric_tariff.n_monthly_demand_tiers ) >= + p.s.electric_tariff.demand_lookback_percent * m[Symbol(dv)][1] + ) end end @@ -378,13 +383,14 @@ end function add_coincident_peak_charge_constraints(m, p; _n="") ## Constraint (14a): in each coincident peak period, charged CP demand is the max of demand in all CP time_steps dv = "dvPeakDemandCP" * _n - m[Symbol(dv)] = @variable(m, [p.s.electric_tariff.coincpeak_periods], lower_bound = 0, base_name = dv) - @constraint(m, - [prd in p.s.electric_tariff.coincpeak_periods, + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.electric_tariff.coincpeak_periods], lower_bound = 0, base_name = dv) + @constraint(m, + [s in 1:p.n_scenarios, + prd in p.s.electric_tariff.coincpeak_periods, ts in p.s.electric_tariff.coincident_peak_load_active_time_steps[prd]], - m[Symbol("dvPeakDemandCP"*_n)][prd] >= sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] + m[Symbol("dvPeakDemandCP"*_n)][s, prd] >= sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - ) + ) end @@ -399,14 +405,14 @@ function add_elec_utility_expressions(m, p; _n="") end m[Symbol("TotalEnergyChargesUtil"*_n)] = @expression(m, p.pwf_e * p.hours_per_time_step * - sum( p.s.electric_tariff.energy_rates[ts, tier] * m[Symbol("dvGridPurchase"*_n)][ts, tier] - for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) + sum( p.scenario_probabilities[s] * p.s.electric_tariff.energy_rates[ts, tier] * m[Symbol("dvGridPurchase"*_n)][s, ts, tier] + for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) ) if !isempty(p.s.electric_tariff.tou_demand_rates) m[Symbol("DemandTOUCharges"*_n)] = @expression(m, - p.pwf_e * sum( p.s.electric_tariff.tou_demand_rates[r, tier] * m[Symbol("dvPeakDemandTOU"*_n)][r, tier] - for r in p.ratchets, tier in 1:p.s.electric_tariff.n_tou_demand_tiers) + p.pwf_e * sum( p.scenario_probabilities[s] * p.s.electric_tariff.tou_demand_rates[r, tier] * m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier] + for s in 1:p.n_scenarios, r in p.ratchets, tier in 1:p.s.electric_tariff.n_tou_demand_tiers) ) else m[Symbol("DemandTOUCharges"*_n)] = 0 @@ -414,8 +420,8 @@ function add_elec_utility_expressions(m, p; _n="") if !isempty(p.s.electric_tariff.monthly_demand_rates) m[Symbol("DemandFlatCharges"*_n)] = @expression(m, p.pwf_e * - sum( p.s.electric_tariff.monthly_demand_rates[mth, t] * m[Symbol("dvPeakDemandMonth"*_n)][mth, t] - for mth in p.months, t in 1:p.s.electric_tariff.n_monthly_demand_tiers) + sum( p.scenario_probabilities[s] * p.s.electric_tariff.monthly_demand_rates[mth, t] * m[Symbol("dvPeakDemandMonth"*_n)][s, mth, t] + for s in 1:p.n_scenarios, mth in p.months, t in 1:p.s.electric_tariff.n_monthly_demand_tiers) ) else m[Symbol("DemandFlatCharges"*_n)] = 0 @@ -432,15 +438,15 @@ function add_elec_utility_expressions(m, p; _n="") end if m[Symbol("TotalMinCharge"*_n)] >= 1e-2 - add_mincharge_constraint(m, p) + add_mincharge_constraint(m, p; _n=_n) else @constraint(m, m[Symbol("MinChargeAdder"*_n)] == 0) end if !isempty(p.s.electric_tariff.coincpeak_periods) m[Symbol("TotalCPCharges"*_n)] = @expression(m, p.pwf_e * - sum( p.s.electric_tariff.coincident_peak_load_charge_per_kw[prd] * m[Symbol("dvPeakDemandCP"*_n)][prd] - for prd in p.s.electric_tariff.coincpeak_periods ) + sum( p.scenario_probabilities[s] * p.s.electric_tariff.coincident_peak_load_charge_per_kw[prd] * m[Symbol("dvPeakDemandCP"*_n)][s, prd] + for s in 1:p.n_scenarios, prd in p.s.electric_tariff.coincpeak_periods ) ) else m[Symbol("TotalCPCharges"*_n)] = 0 diff --git a/src/constraints/emissions_constraints.jl b/src/constraints/emissions_constraints.jl index 5d8995aef..dc21ec796 100644 --- a/src/constraints/emissions_constraints.jl +++ b/src/constraints/emissions_constraints.jl @@ -66,16 +66,16 @@ Function to calculate annual emissions from onsite fuel consumption. """ function calc_yr1_emissions_from_onsite_fuel(m,p; tech_array=p.techs.fuel_burning) # also run this with p.techs.boiler yr1_emissions_onsite_fuel_lbs_CO2 = @expression(m,p.hours_per_time_step* - sum(m[:dvFuelUsage][t,ts]*p.tech_emissions_factors_CO2[t] for t in tech_array, ts in p.time_steps)) + sum(p.scenario_probabilities[s]*m[:dvFuelUsage][s,t,ts]*p.tech_emissions_factors_CO2[t] for s in 1:p.n_scenarios, t in tech_array, ts in p.time_steps)) yr1_emissions_onsite_fuel_lbs_NOx = @expression(m,p.hours_per_time_step* - sum(m[:dvFuelUsage][t,ts]*p.tech_emissions_factors_NOx[t] for t in tech_array, ts in p.time_steps)) + sum(p.scenario_probabilities[s]*m[:dvFuelUsage][s,t,ts]*p.tech_emissions_factors_NOx[t] for s in 1:p.n_scenarios, t in tech_array, ts in p.time_steps)) yr1_emissions_onsite_fuel_lbs_SO2 = @expression(m,p.hours_per_time_step* - sum(m[:dvFuelUsage][t,ts]*p.tech_emissions_factors_SO2[t] for t in tech_array, ts in p.time_steps)) + sum(p.scenario_probabilities[s]*m[:dvFuelUsage][s,t,ts]*p.tech_emissions_factors_SO2[t] for s in 1:p.n_scenarios, t in tech_array, ts in p.time_steps)) yr1_emissions_onsite_fuel_lbs_PM25 = @expression(m,p.hours_per_time_step* - sum(m[:dvFuelUsage][t,ts]*p.tech_emissions_factors_PM25[t] for t in tech_array, ts in p.time_steps)) + sum(p.scenario_probabilities[s]*m[:dvFuelUsage][s,t,ts]*p.tech_emissions_factors_PM25[t] for s in 1:p.n_scenarios, t in tech_array, ts in p.time_steps)) return yr1_emissions_onsite_fuel_lbs_CO2, yr1_emissions_onsite_fuel_lbs_NOx, @@ -97,16 +97,16 @@ Function to calculate annual emissions from grid electricity consumption. """ function calc_yr1_emissions_from_elec_grid_purchase(m,p) yr1_emissions_from_elec_grid_lbs_CO2 = @expression(m,p.hours_per_time_step* - sum(m[:dvGridPurchase][ts, tier]*p.s.electric_utility.emissions_factor_series_lb_CO2_per_kwh[ts] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) + sum(p.scenario_probabilities[s] * m[:dvGridPurchase][s, ts, tier]*p.s.electric_utility.emissions_factor_series_lb_CO2_per_kwh[ts] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) yr1_emissions_from_elec_grid_lbs_NOx = @expression(m,p.hours_per_time_step* - sum(m[:dvGridPurchase][ts, tier]*p.s.electric_utility.emissions_factor_series_lb_NOx_per_kwh[ts] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) + sum(p.scenario_probabilities[s] * m[:dvGridPurchase][s, ts, tier]*p.s.electric_utility.emissions_factor_series_lb_NOx_per_kwh[ts] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) yr1_emissions_from_elec_grid_lbs_SO2 = @expression(m,p.hours_per_time_step* - sum(m[:dvGridPurchase][ts, tier]*p.s.electric_utility.emissions_factor_series_lb_SO2_per_kwh[ts] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) + sum(p.scenario_probabilities[s] * m[:dvGridPurchase][s, ts, tier]*p.s.electric_utility.emissions_factor_series_lb_SO2_per_kwh[ts] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) yr1_emissions_from_elec_grid_lbs_PM25 = @expression(m,p.hours_per_time_step* - sum(m[:dvGridPurchase][ts, tier]*p.s.electric_utility.emissions_factor_series_lb_PM25_per_kwh[ts] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) + sum(p.scenario_probabilities[s] * m[:dvGridPurchase][s, ts, tier]*p.s.electric_utility.emissions_factor_series_lb_PM25_per_kwh[ts] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)) return yr1_emissions_from_elec_grid_lbs_CO2, yr1_emissions_from_elec_grid_lbs_NOx, @@ -120,24 +120,24 @@ function calc_yr1_emissions_offset_from_elec_exports(m, p) return 0.0, 0.0, 0.0, 0.0 end yr1_emissions_offset_from_elec_exports_lbs_CO2 = @expression(m, p.hours_per_time_step * - sum(m[:dvProductionToGrid][t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_CO2_per_kwh[ts]) - for t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) + sum(p.scenario_probabilities[s] * m[:dvProductionToGrid][s,t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_CO2_per_kwh[ts]) + for s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) ) # if battery ends up being able to discharge to grid, need to incorporate here- might require complex tracking of what's charging battery yr1_emissions_offset_from_elec_exports_lbs_NOx = @expression(m, p.hours_per_time_step * - sum(m[:dvProductionToGrid][t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_NOx_per_kwh[ts]) - for t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) + sum(p.scenario_probabilities[s] * m[:dvProductionToGrid][s,t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_NOx_per_kwh[ts]) + for s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) ) yr1_emissions_offset_from_elec_exports_lbs_SO2 = @expression(m, p.hours_per_time_step * - sum(m[:dvProductionToGrid][t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_SO2_per_kwh[ts]) - for t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) + sum(p.scenario_probabilities[s] * m[:dvProductionToGrid][s,t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_SO2_per_kwh[ts]) + for s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) ) yr1_emissions_offset_from_elec_exports_lbs_PM25 = @expression(m, p.hours_per_time_step * - sum(m[:dvProductionToGrid][t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_PM25_per_kwh[ts]) - for t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) + sum(p.scenario_probabilities[s] * m[:dvProductionToGrid][s,t,u,ts] * (p.s.electric_utility.emissions_factor_series_lb_PM25_per_kwh[ts]) + for s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps, u in p.export_bins_by_tech[t]) ) return yr1_emissions_offset_from_elec_exports_lbs_CO2, diff --git a/src/constraints/generator_constraints.jl b/src/constraints/generator_constraints.jl index 34d606dca..cb8730adb 100644 --- a/src/constraints/generator_constraints.jl +++ b/src/constraints/generator_constraints.jl @@ -5,57 +5,57 @@ function add_fuel_burn_constraints(m,p) electric_efficiency_half_load=p.s.generator.electric_efficiency_half_load, fuel_higher_heating_value_kwh_per_unit=p.s.generator.fuel_higher_heating_value_kwh_per_gal ) - @constraint(m, [t in p.techs.gen, ts in p.time_steps], - m[:dvFuelUsage][t, ts] == (fuel_slope_gal_per_kwhe * p.s.generator.fuel_higher_heating_value_kwh_per_gal * - p.production_factor[t, ts] * p.hours_per_time_step * m[:dvRatedProduction][t, ts]) + - (fuel_intercept_gal_per_hr * p.s.generator.fuel_higher_heating_value_kwh_per_gal * p.hours_per_time_step * m[:binGenIsOnInTS][t, ts]) - ) - @constraint(m, - sum(m[:dvFuelUsage][t, ts] for t in p.techs.gen, ts in p.time_steps) <= - p.s.generator.fuel_avail_gal * p.s.generator.fuel_higher_heating_value_kwh_per_gal - ) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps], + m[:dvFuelUsage][s, t, ts] == (fuel_slope_gal_per_kwhe * p.s.generator.fuel_higher_heating_value_kwh_per_gal * + p.production_factor_by_scenario[s][t][ts] * p.hours_per_time_step * m[:dvRatedProduction][s, t, ts]) + + (fuel_intercept_gal_per_hr * p.s.generator.fuel_higher_heating_value_kwh_per_gal * p.hours_per_time_step * m[:binGenIsOnInTS][s, t, ts]) + ) + @constraint(m, [s in 1:p.n_scenarios], + sum(m[:dvFuelUsage][s, t, ts] for t in p.techs.gen, ts in p.time_steps) <= + p.s.generator.fuel_avail_gal * p.s.generator.fuel_higher_heating_value_kwh_per_gal + ) end function add_binGenIsOnInTS_constraints(m,p) # Generator must be on for nonnegative output - @constraint(m, [t in p.techs.gen, ts in p.time_steps], - m[:dvRatedProduction][t, ts] <= p.max_sizes[t] * m[:binGenIsOnInTS][t, ts] - ) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps], + m[:dvRatedProduction][s, t, ts] <= p.max_sizes[t] * m[:binGenIsOnInTS][s, t, ts] + ) # Note: min_turn_down_fraction is only enforced when `off_grid_flag` is true and in p.time_steps_with_grid, but not for grid outages for on-grid analyses if p.s.settings.off_grid_flag - @constraint(m, [t in p.techs.gen, ts in p.time_steps_without_grid], - p.s.generator.min_turn_down_fraction * m[:dvSize][t] - m[:dvRatedProduction][t, ts] <= - p.max_sizes[t] * (1 - m[:binGenIsOnInTS][t, ts]) - ) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps_without_grid], + p.s.generator.min_turn_down_fraction * m[:dvSize][t] - m[:dvRatedProduction][s, t, ts] <= + p.max_sizes[t] * (1 - m[:binGenIsOnInTS][s, t, ts]) + ) else - @constraint(m, [t in p.techs.gen, ts in p.time_steps_with_grid], - p.s.generator.min_turn_down_fraction * m[:dvSize][t] - m[:dvRatedProduction][t, ts] <= - p.max_sizes[t] * (1 - m[:binGenIsOnInTS][t, ts]) - ) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps_with_grid], + p.s.generator.min_turn_down_fraction * m[:dvSize][t] - m[:dvRatedProduction][s, t, ts] <= + p.max_sizes[t] * (1 - m[:binGenIsOnInTS][s, t, ts]) + ) end end function add_gen_can_run_constraints(m,p) if p.s.generator.only_runs_during_grid_outage - for ts in p.time_steps_with_grid, t in p.techs.gen - fix(m[:dvRatedProduction][t, ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps_with_grid, t in p.techs.gen + fix(m[:dvRatedProduction][s, t, ts], 0.0, force=true) end end if !(p.s.generator.sells_energy_back_to_grid) - for t in p.techs.gen, u in p.export_bins_by_tech[t], ts in p.time_steps - fix(m[:dvProductionToGrid][t, u, ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.techs.gen, u in p.export_bins_by_tech[t], ts in p.time_steps + fix(m[:dvProductionToGrid][s, t, u, ts], 0.0, force=true) end end end function add_gen_rated_prod_constraint(m, p) - @constraint(m, [t in p.techs.gen, ts in p.time_steps], - m[:dvSize][t] >= m[:dvRatedProduction][t, ts] - ) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps], + m[:dvSize][t] >= m[:dvRatedProduction][s, t, ts] + ) end @@ -71,10 +71,10 @@ function add_gen_constraints(m, p) add_gen_rated_prod_constraint(m,p) m[:TotalGenPerUnitProdOMCosts] = @expression(m, p.third_party_factor * p.pwf_om * - sum(p.s.generator.om_cost_per_kwh * p.hours_per_time_step * - m[:dvRatedProduction][t, ts] for t in p.techs.gen, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.s.generator.om_cost_per_kwh * p.hours_per_time_step * + m[:dvRatedProduction][s, t, ts] for s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps) ) m[:TotalGenFuelCosts] = @expression(m, - sum(p.pwf_fuel[t] * m[:dvFuelUsage][t,ts] * p.fuel_cost_per_kwh[t][ts] for t in p.techs.gen, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.pwf_fuel[t] * m[:dvFuelUsage][s, t,ts] * p.fuel_cost_per_kwh[t][ts] for s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps) ) end diff --git a/src/constraints/ghp_constraints.jl b/src/constraints/ghp_constraints.jl index 0ce463104..f7fd09f59 100644 --- a/src/constraints/ghp_constraints.jl +++ b/src/constraints/ghp_constraints.jl @@ -32,80 +32,80 @@ function add_ghp_constraints(m, p; _n="") if length(p.ghp_options) == 1 g = p.ghp_options[1] if p.s.ghp_option_list[g].can_serve_dhw - @constraint(m, GHPDHWandSpaceHeatingCon[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["GHP","DomesticHotWater",ts] + m[Symbol("dvHeatingProduction"*_n)]["GHP","SpaceHeating",ts] == + @constraint(m, GHPDHWandSpaceHeatingCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s, "GHP","DomesticHotWater",ts] + m[Symbol("dvHeatingProduction"*_n)][s, "GHP","SpaceHeating",ts] == (p.space_heating_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_heating_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) - @constraint(m, GHPDHWLimitCon[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["GHP","DomesticHotWater",ts] <= + @constraint(m, GHPDHWLimitCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s, "GHP","DomesticHotWater",ts] <= p.ghp_heating_thermal_load_served_kw[g,ts] * m[Symbol("binGHP"*_n)][g] ) else - @constraint(m, GHPDHWCon[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["GHP","DomesticHotWater",ts] == 0.0 + @constraint(m, GHPDHWCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s, "GHP","DomesticHotWater",ts] == 0.0 ) - @constraint(m, GHPSpaceHeatingCon[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["GHP","SpaceHeating",ts] == + @constraint(m, GHPSpaceHeatingCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s, "GHP","SpaceHeating",ts] == (p.space_heating_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_heating_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) end - @constraint(m, GHPCoolingCon[ts in p.time_steps], - m[Symbol("dvCoolingProduction"*_n)]["GHP",ts] == + @constraint(m, GHPCoolingCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvCoolingProduction"*_n)][s, "GHP",ts] == (p.cooling_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_cooling_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) else dv = "dvGHPHeatingProduction"*_n - m[Symbol(dv)] = @variable(m, [p.ghp_options, p.heating_loads, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.ghp_options, p.heating_loads, p.time_steps], base_name=dv, lower_bound=0) dv = "dvGHPCoolingProduction"*_n - m[Symbol(dv)] = @variable(m, [p.ghp_options, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.ghp_options, p.time_steps], base_name=dv, lower_bound=0) for g in p.ghp_options if !isnothing(p.s.ghp_option_list[g]) if p.s.ghp_option_list[g].can_serve_dhw con = "GHPDHWandSpaceHeatingConOption"*string(g)*_n - m[Symbol(con)] = @constraint(m, [ts in p.time_steps], - m[Symbol("dvGHPHeatingProduction"*_n)][g,"DomesticHotWater",ts] + m[Symbol("dvGHPHeatingProduction"*_n)][g,"SpaceHeating",ts] == + m[Symbol(con)] = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvGHPHeatingProduction"*_n)][s, g,"DomesticHotWater",ts] + m[Symbol("dvGHPHeatingProduction"*_n)][s, g,"SpaceHeating",ts] == (p.space_heating_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_heating_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) con = "GHPSpaceHeatingLimitConOption"*string(g)*_n - m[Symbol(con)] = @constraint(m, [ts in p.time_steps], - m[Symbol("dvGHPHeatingProduction"*_n)][g,"DomesticHotWater",ts] <= + m[Symbol(con)] = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvGHPHeatingProduction"*_n)][s, g,"DomesticHotWater",ts] <= p.ghp_heating_thermal_load_served_kw[g,ts] * m[Symbol("binGHP"*_n)][g] ) else con = "GHPDHWConOption"*string(g)*_n - m[Symbol(con)] = @constraint(m, [ts in p.time_steps], - m[Symbol("dvGHPHeatingProduction"*_n)][g,"DomesticHotWater",ts] == 0.0 + m[Symbol(con)] = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvGHPHeatingProduction"*_n)][s, g,"DomesticHotWater",ts] == 0.0 ) con = "GHPSpaceHeatingConOption"*string(g)*_n - m[Symbol(con)] = @constraint(m, [ts in p.time_steps], - m[Symbol("dvGHPHeatingProduction"*_n)][g,"SpaceHeating",ts] == + m[Symbol(con)] = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvGHPHeatingProduction"*_n)][s, g,"SpaceHeating",ts] == (p.space_heating_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_heating_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) end con = "GHPCoolingConOption"*string(g)*_n - m[Symbol(con)] = @constraint(m, [g in p.ghp_options, ts in p.time_steps], - m[Symbol("dvGHPCoolingProduction"*_n)][g,ts] == + m[Symbol(con)] = @constraint(m, [s in 1:p.n_scenarios, g in p.ghp_options, ts in p.time_steps], + m[Symbol("dvGHPCoolingProduction"*_n)][s, g,ts] == (p.cooling_thermal_load_reduction_with_ghp_kw[g,ts] + p.ghp_cooling_thermal_load_served_kw[g,ts]) * m[Symbol("binGHP"*_n)][g] ) end end - @constraint(m, GHPHeatingReconciliation[q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["GHP",q,ts] == sum(m[Symbol("dvGHPHeatingProduction"*_n)][g,q,ts] for g in p.ghp_options) + @constraint(m, GHPHeatingReconciliation[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s, "GHP",q,ts] == sum(m[Symbol("dvGHPHeatingProduction"*_n)][s, g,q,ts] for g in p.ghp_options) ) - @constraint(m, GHPCoolingReconciliation[ts in p.time_steps], - m[Symbol("dvCoolingProduction"*_n)]["GHP",ts] == sum(m[Symbol("dvGHPCoolingProduction"*_n)][g,ts] for g in p.ghp_options) + @constraint(m, GHPCoolingReconciliation[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvCoolingProduction"*_n)][s, "GHP",ts] == sum(m[Symbol("dvGHPCoolingProduction"*_n)][s, g,ts] for g in p.ghp_options) ) end # TODO determine whether process heat or steam turbine input is feasible with GHP, or is this sufficient? - @constraint(m, GHPProcessHeatCon[ts in p.time_steps], m[Symbol("dvHeatingProduction"*_n)]["GHP","ProcessHeat",ts] == 0.0) - @constraint(m, GHPHeatFlowCon[q in p.heating_loads, ts in p.time_steps], m[Symbol("dvProductionToWaste"*_n)]["GHP",q,ts] + sum(m[Symbol("dvHeatToStorage"*_n)][b,"GHP",q,ts] for b in p.s.storage.types.hot) <= m[Symbol("dvHeatingProduction"*_n)]["GHP",q,ts]) + @constraint(m, GHPProcessHeatCon[s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvHeatingProduction"*_n)][s, "GHP","ProcessHeat",ts] == 0.0) + @constraint(m, GHPHeatFlowCon[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], m[Symbol("dvProductionToWaste"*_n)][s, "GHP",q,ts] + sum(m[Symbol("dvHeatToStorage"*_n)][s, b,"GHP",q,ts] for b in p.s.storage.types.hot) <= m[Symbol("dvHeatingProduction"*_n)][s, "GHP",q,ts]) end \ No newline at end of file diff --git a/src/constraints/load_balance.jl b/src/constraints/load_balance.jl index 057330e55..fe4cb560a 100644 --- a/src/constraints/load_balance.jl +++ b/src/constraints/load_balance.jl @@ -4,59 +4,65 @@ function add_elec_load_balance_constraints(m, p; _n="") ##Constraint (8a): Electrical Load Balancing with Grid if isempty(p.s.electric_tariff.export_bins) - conrefs = @constraint(m, [ts in p.time_steps_with_grid], - sum(p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] for t in p.techs.elec) - + sum(m[Symbol("dvDischargeFromStorage"*_n)][b,ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + conrefs = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + sum(p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] for t in p.techs.elec) + + sum(m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] for b in p.s.storage.types.elec) + + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) == - sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) - + sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) - + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) - + p.s.electric_load.loads_kw[ts] + sum(sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] for t in p.techs.elec) + + sum(m[Symbol("dvGridToStorage"*_n)][s, b, ts] for b in p.s.storage.types.elec) + + sum(m[Symbol("dvCoolingProduction"*_n)][s, t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) + + sum(m[Symbol("dvHeatingProduction"*_n)][s, t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) + + p.loads_kw_by_scenario[s][ts] - p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cop["ExistingChiller"][ts] + sum(p.ghp_electric_consumption_kw[g,ts] * m[Symbol("binGHP"*_n)][g] for g in p.ghp_options) ) + for (i, cr) in enumerate(conrefs) + # Extract s and ts from the index - conrefs is now a 2D array + idx = CartesianIndices(conrefs)[i] + JuMP.set_name(cr, "con_load_balance"*_n*string("_s", idx[1], "_t", idx[2])) + end else - conrefs = @constraint(m, [ts in p.time_steps_with_grid], - sum(p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] for t in p.techs.elec) - + sum(m[Symbol("dvDischargeFromStorage"*_n)][b,ts] for b in p.s.storage.types.elec ) - + sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) + conrefs = @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + sum(p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] for t in p.techs.elec) + + sum(m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] for b in p.s.storage.types.elec ) + + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) == - sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t]) - + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) - + sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) - + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) - + p.s.electric_load.loads_kw[ts] + sum(sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + sum(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts] for u in p.export_bins_by_tech[t]) + + m[Symbol("dvCurtail"*_n)][s, t, ts] for t in p.techs.elec) + + sum(m[Symbol("dvGridToStorage"*_n)][s, b, ts] for b in p.s.storage.types.elec) + + sum(m[Symbol("dvCoolingProduction"*_n)][s, t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) + + sum(m[Symbol("dvHeatingProduction"*_n)][s, t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) + + p.loads_kw_by_scenario[s][ts] - p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cop["ExistingChiller"][ts] + sum(p.ghp_electric_consumption_kw[g,ts] * m[Symbol("binGHP"*_n)][g] for g in p.ghp_options) ) + for (i, cr) in enumerate(conrefs) + # Extract s and ts from the index - conrefs is now a 2D array + idx = CartesianIndices(conrefs)[i] + JuMP.set_name(cr, "con_load_balance"*_n*string("_s", idx[1], "_t", idx[2])) + end end - - for (i, cr) in enumerate(conrefs) - JuMP.set_name(cr, "con_load_balance"*_n*string("_t", i)) - end ##Constraint (8b): Electrical Load Balancing without Grid if !p.s.settings.off_grid_flag # load balancing constraint for grid-connected runs - @constraint(m, [ts in p.time_steps_without_grid], - sum(p.production_factor[t,ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] for t in p.techs.elec) - + sum(m[Symbol("dvDischargeFromStorage"*_n)][b,ts] for b in p.s.storage.types.elec) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + sum(p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] for t in p.techs.elec) + + sum(m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] for b in p.s.storage.types.elec) == - sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) + sum(sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] for t in p.techs.elec) + p.s.electric_load.critical_loads_kw[ts] ) else # load balancing constraint for off-grid runs - @constraint(m, [ts in p.time_steps_without_grid], - sum(p.production_factor[t,ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] for t in p.techs.elec) - + sum(m[Symbol("dvDischargeFromStorage"*_n)][b,ts] for b in p.s.storage.types.elec) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + sum(p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] for t in p.techs.elec) + + sum(m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] for b in p.s.storage.types.elec) == - sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) + sum(sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] for t in p.techs.elec) + p.s.electric_load.critical_loads_kw[ts] * m[Symbol("dvOffgridLoadServedFraction"*_n)][ts] ) ##Constraint : For off-grid scenarios, annual load served must be >= minimum percent specified @@ -73,29 +79,29 @@ end function add_production_constraints(m, p; _n="") # Constraint (4d): Electrical production sent to storage or export must be less than technology's rated production if isempty(p.s.electric_tariff.export_bins) - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] <= - p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t, ts] + p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t, ts] ) else - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] - + sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t]) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] + + sum(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts] for u in p.export_bins_by_tech[t]) <= - p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t, ts] + p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t, ts] ) end # Constraint (4e): Electrical production sent to storage or curtailed must be less than technology's rated production - no grid - @constraint(m, [t in p.techs.elec, ts in p.time_steps_without_grid], - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][t, ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_without_grid], + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) + + m[Symbol("dvCurtail"*_n)][s, t, ts] <= - p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t, ts] - ) + p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t, ts] + ) end @@ -118,26 +124,26 @@ function add_thermal_load_constraints(m, p; _n="") if !isempty(p.techs.heating) if !isempty(p.techs.steam_turbine) - @constraint(m, HeatLoadBalanceCon[q in p.heating_loads, ts in p.time_steps_with_grid], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for b in p.s.storage.types.hot) + @constraint(m, HeatLoadBalanceCon[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps_with_grid], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for b in p.s.storage.types.hot) == p.heating_loads_kw[q][ts] - + sum(m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot, t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvCoolingProduction"*_n)][t,ts] / p.thermal_cop[t] for t in p.absorption_chillers_using_heating_load[q]) - + sum(m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] for t in p.techs.can_supply_steam_turbine) - + sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] for b in p.s.storage.types.hot) + + sum(m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot, t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvCoolingProduction"*_n)][s,t,ts] / p.thermal_cop[t] for t in p.absorption_chillers_using_heating_load[q]) + + sum(m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] for t in p.techs.can_supply_steam_turbine) + + sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] for b in p.s.storage.types.hot) ) else - @constraint(m, HeatLoadBalanceCon[q in p.heating_loads, ts in p.time_steps_with_grid], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for b in p.s.storage.types.hot) + @constraint(m, HeatLoadBalanceCon[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps_with_grid], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for b in p.s.storage.types.hot) == p.heating_loads_kw[q][ts] - + sum(m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot, t in union(p.techs.heating, p.techs.chp)) - + sum(m[Symbol("dvCoolingProduction"*_n)][t,ts] / p.thermal_cop[t] for t in p.absorption_chillers_using_heating_load[q]) + + sum(m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot, t in union(p.techs.heating, p.techs.chp)) + + sum(m[Symbol("dvCoolingProduction"*_n)][s,t,ts] / p.thermal_cop[t] for t in p.absorption_chillers_using_heating_load[q]) ) end @@ -146,12 +152,12 @@ function add_thermal_load_constraints(m, p; _n="") if !isempty(p.techs.cooling) ##Constraint (5a): Cold thermal loads - @constraint(m, [ts in p.time_steps_with_grid], - sum(m[Symbol("dvCoolingProduction"*_n)][t,ts] for t in p.techs.cooling) - + sum(m[Symbol("dvDischargeFromStorage"*_n)][b,ts] for b in p.s.storage.types.cold) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + sum(m[Symbol("dvCoolingProduction"*_n)][s,t,ts] for t in p.techs.cooling) + + sum(m[Symbol("dvDischargeFromStorage"*_n)][s,b,ts] for b in p.s.storage.types.cold) == p.s.cooling_load.loads_kw_thermal[ts] - + sum(m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for b in p.s.storage.types.cold, t in p.techs.cooling) + + sum(m[Symbol("dvProductionToStorage"*_n)][s,b,t,ts] for b in p.s.storage.types.cold, t in p.techs.cooling) ) end end diff --git a/src/constraints/operating_reserve_constraints.jl b/src/constraints/operating_reserve_constraints.jl index 0cf21ea8b..f32cf2ea2 100644 --- a/src/constraints/operating_reserve_constraints.jl +++ b/src/constraints/operating_reserve_constraints.jl @@ -3,46 +3,46 @@ function add_operating_reserve_constraints(m, p; _n="") # Calculate operating reserves (OR) required # 1. Production going to load from providing_oper_res - m[:ProductionToLoadOR] = @expression(m, [t in p.techs.providing_oper_res, ts in p.time_steps_without_grid], - p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][t,ts] - - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) - - m[Symbol("dvCurtail"*_n)][t, ts] + m[:ProductionToLoadOR] = @expression(m, [s in 1:p.n_scenarios, t in p.techs.providing_oper_res, ts in p.time_steps_without_grid], + p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvRatedProduction"*_n)][s, t,ts] - + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for b in p.s.storage.types.elec) - + m[Symbol("dvCurtail"*_n)][s, t, ts] ) # 2. Total OR required by requiring_oper_res & Load - m[:OpResRequired] = @expression(m, [ts in p.time_steps_without_grid], - sum(m[:ProductionToLoadOR][t,ts] * p.techs_operating_reserve_req_fraction[t] for t in p.techs.requiring_oper_res) + m[:OpResRequired] = @expression(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + sum(m[:ProductionToLoadOR][s, t,ts] * p.techs_operating_reserve_req_fraction[t] for t in p.techs.requiring_oper_res) + p.s.electric_load.critical_loads_kw[ts] * m[Symbol("dvOffgridLoadServedFraction"*_n)][ts] * p.s.electric_load.operating_reserve_required_fraction ) # 3. Operating reserve provided - battery - @constraint(m, [b in p.s.storage.types.elec, ts in p.time_steps_without_grid], - m[Symbol("dvOpResFromBatt"*_n)][b,ts] <= (m[Symbol("dvStoredEnergy"*_n)][b, ts-1] - p.s.storage.attr[b].soc_min_fraction * m[Symbol("dvStorageEnergy"*_n)][b]) / p.hours_per_time_step - - (m[Symbol("dvDischargeFromStorage"*_n)][b,ts] / p.s.storage.attr[b].discharge_efficiency) + @constraint(m, [s in 1:p.n_scenarios, b in p.s.storage.types.elec, ts in p.time_steps_without_grid], + m[Symbol("dvOpResFromBatt"*_n)][s, b,ts] <= (m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] - p.s.storage.attr[b].soc_min_fraction * m[Symbol("dvStorageEnergy"*_n)][b]) / p.hours_per_time_step + - (m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] / p.s.storage.attr[b].discharge_efficiency) ) - @constraint(m, [b in p.s.storage.types.elec, ts in p.time_steps_without_grid], - m[Symbol("dvOpResFromBatt"*_n)][b,ts] <= m[Symbol("dvStoragePower"*_n)][b] - m[Symbol("dvDischargeFromStorage"*_n)][b,ts] / p.s.storage.attr[b].discharge_efficiency + @constraint(m, [s in 1:p.n_scenarios, b in p.s.storage.types.elec, ts in p.time_steps_without_grid], + m[Symbol("dvOpResFromBatt"*_n)][s, b,ts] <= m[Symbol("dvStoragePower"*_n)][b] - m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] / p.s.storage.attr[b].discharge_efficiency ) # 4. Operating reserve provided - techs - @constraint(m, [t in p.techs.providing_oper_res, ts in p.time_steps_without_grid], - m[Symbol("dvOpResFromTechs"*_n)][t,ts] <= (p.production_factor[t, ts] * p.levelization_factor[t] * m[Symbol("dvSize"*_n)][t] - - m[:ProductionToLoadOR][t,ts]) * (1 - p.techs_operating_reserve_req_fraction[t]) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.providing_oper_res, ts in p.time_steps_without_grid], + m[Symbol("dvOpResFromTechs"*_n)][s, t,ts] <= (p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[Symbol("dvSize"*_n)][t] - + m[:ProductionToLoadOR][s, t,ts]) * (1 - p.techs_operating_reserve_req_fraction[t]) ) # 5a. Upper bound on dvOpResFromTechs (for generator techs). Note: will need to add new constraints for each new tech that can provide operating reserves - @constraint(m, [t in p.techs.gen, ts in p.time_steps_without_grid], - m[Symbol("dvOpResFromTechs"*_n)][t,ts] <= m[:binGenIsOnInTS][t, ts] * p.max_sizes[t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps_without_grid], + m[Symbol("dvOpResFromTechs"*_n)][s, t,ts] <= m[:binGenIsOnInTS][s, t, ts] * p.max_sizes[t] ) # 5b. Upper bound on dvOpResFromTechs (for pv techs) - @constraint(m, [t in p.techs.pv, ts in p.time_steps_without_grid], - m[Symbol("dvOpResFromTechs"*_n)][t,ts] <= p.max_sizes[t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.pv, ts in p.time_steps_without_grid], + m[Symbol("dvOpResFromTechs"*_n)][s, t,ts] <= p.max_sizes[t] ) - m[:OpResProvided] = @expression(m, [ts in p.time_steps_without_grid], - sum(m[Symbol("dvOpResFromTechs"*_n)][t,ts] for t in p.techs.providing_oper_res) - + sum(m[Symbol("dvOpResFromBatt"*_n)][b,ts] for b in p.s.storage.types.elec) + m[:OpResProvided] = @expression(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + sum(m[Symbol("dvOpResFromTechs"*_n)][s, t,ts] for t in p.techs.providing_oper_res) + + sum(m[Symbol("dvOpResFromBatt"*_n)][s, b,ts] for b in p.s.storage.types.elec) ) # 6. OpRes provided must be greater than OpRes required - @constraint(m, [ts in p.time_steps_without_grid], - m[:OpResProvided][ts] >= m[:OpResRequired][ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + m[:OpResProvided][s, ts] >= m[:OpResRequired][s, ts] ) end diff --git a/src/constraints/outage_constraints.jl b/src/constraints/outage_constraints.jl index d7fc82a44..be3dcb579 100644 --- a/src/constraints/outage_constraints.jl +++ b/src/constraints/outage_constraints.jl @@ -275,9 +275,9 @@ function add_binMGCHPIsOnInTS_constraints(m, p; _n="") end function add_MG_storage_dispatch_constraints(m,p) - # initial SOC at start of each outage equals the grid-optimal SOC + # initial SOC at start of each outage equals the grid-optimal SOC (probability-weighted average across OUU scenarios) @constraint(m, [s in p.s.electric_utility.scenarios, tz in p.s.electric_utility.outage_start_time_steps], - m[:dvMGStoredEnergy][s, tz, 0] <= m[:dvStoredEnergy]["ElectricStorage", tz] + m[:dvMGStoredEnergy][s, tz, 0] <= sum(p.scenario_probabilities[scen] * m[:dvStoredEnergy][scen, "ElectricStorage", tz] for scen in 1:p.n_scenarios) ) # state of charge diff --git a/src/constraints/production_incentive_constraints.jl b/src/constraints/production_incentive_constraints.jl index 6cbe98d46..2aa4d3bd2 100644 --- a/src/constraints/production_incentive_constraints.jl +++ b/src/constraints/production_incentive_constraints.jl @@ -17,7 +17,7 @@ function add_prod_incent_vars_and_constraints(m, p) ##Constraint (6a)-2: Production Incentive According to Production @constraint(m, IncentByProductionCon[t in p.techs.pbi], m[:dvProdIncent][t] <= p.hours_per_time_step * p.pbi_benefit_per_kwh[t] * p.pbi_pwf[t] * p.third_party_factor * - sum(p.production_factor[t, ts] * m[:dvRatedProduction][t,ts] for ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.production_factor_by_scenario[s][t][ts] * m[:dvRatedProduction][s, t,ts] for s in 1:p.n_scenarios, ts in p.time_steps) ) ##Constraint (6b): System size max to achieve production incentive @constraint(m, IncentBySystemSizeCon[t in p.techs.pbi], diff --git a/src/constraints/renewable_energy_constraints.jl b/src/constraints/renewable_energy_constraints.jl index 4c8bb1003..cf42379a1 100644 --- a/src/constraints/renewable_energy_constraints.jl +++ b/src/constraints/renewable_energy_constraints.jl @@ -61,19 +61,19 @@ function add_re_elec_calcs(m,p) # Note: when we add capability for battery to discharge to grid, need to make sure only RE that is being consumed # onsite is counted so battery doesn't become a back door for RE to grid. m[:AnnualOnsiteREEleckWh] = @expression(m, p.hours_per_time_step * ( - sum(p.production_factor[t,ts] * p.levelization_factor[t] * m[:dvRatedProduction][t,ts] * - p.tech_renewable_energy_fraction[t] for t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps + sum(p.scenario_probabilities[s] * p.production_factor_by_scenario[s][t][ts] * p.levelization_factor[t] * m[:dvRatedProduction][s, t,ts] * + p.tech_renewable_energy_fraction[t] for s in 1:p.n_scenarios, t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps ) - #total RE elec generation, excl steam turbine - sum(m[:dvProductionToStorage][b,t,ts]*p.tech_renewable_energy_fraction[t]*( + sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s, b,t,ts]*p.tech_renewable_energy_fraction[t]*( 1-p.s.storage.attr[b].charge_efficiency*p.s.storage.attr[b].discharge_efficiency) - for t in setdiff(p.techs.elec, p.techs.steam_turbine), b in p.s.storage.types.elec, ts in p.time_steps + for s in 1:p.n_scenarios, t in setdiff(p.techs.elec, p.techs.steam_turbine), b in p.s.storage.types.elec, ts in p.time_steps ) - #minus battery efficiency losses - sum(m[:dvCurtail][t,ts] * p.tech_renewable_energy_fraction[t] - for t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps + sum(p.scenario_probabilities[s] * m[:dvCurtail][s, t,ts] * p.tech_renewable_energy_fraction[t] + for s in 1:p.n_scenarios, t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps ) - # minus curtailment (1 - p.s.site.include_exported_renewable_electricity_in_total) * - sum(m[:dvProductionToGrid][t,u,ts]*p.tech_renewable_energy_fraction[t] - for t in setdiff(p.techs.elec, p.techs.steam_turbine), u in p.export_bins_by_tech[t], ts in p.time_steps + sum(p.scenario_probabilities[s] * m[:dvProductionToGrid][s, t,u,ts]*p.tech_renewable_energy_fraction[t] + for s in 1:p.n_scenarios, t in setdiff(p.techs.elec, p.techs.steam_turbine), u in p.export_bins_by_tech[t], ts in p.time_steps ) # minus exported RE, if RE accounting method = 0. ) # + SteamTurbineAnnualREEleckWh # SteamTurbine RE Elec, already adjusted for p.hours_per_time_step @@ -82,11 +82,11 @@ function add_re_elec_calcs(m,p) # Note: when we add capability for battery to discharge to grid, need to subtract out *grid RE* discharged from battery # back to grid so that loop doesn't become a back door for increasing RE. This will require some careful thought! m[:AnnualGridREEleckWh] = @expression(m, p.hours_per_time_step * ( - sum(m[:dvGridPurchase][ts, tier] * p.s.electric_utility.renewable_energy_fraction_series[ts] - for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) # renewable energy from grid - - sum(m[:dvGridToStorage][b, ts] * p.s.electric_utility.renewable_energy_fraction_series[ts] * + sum(p.scenario_probabilities[s] * m[:dvGridPurchase][s, ts, tier] * p.s.electric_utility.renewable_energy_fraction_series[ts] + for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) # renewable energy from grid + - sum(p.scenario_probabilities[s] * m[:dvGridToStorage][s, b, ts] * p.s.electric_utility.renewable_energy_fraction_series[ts] * (1 - p.s.storage.attr[b].charge_efficiency * p.s.storage.attr[b].discharge_efficiency) - for ts in p.time_steps, b in p.s.storage.types.elec + for s in 1:p.n_scenarios, ts in p.time_steps, b in p.s.storage.types.elec ) # minus battery efficiency losses from grid charging storage (assumes all that is charged is discharged) ) ) @@ -97,8 +97,8 @@ function add_re_elec_calcs(m,p) + sum(p.s.electric_load.critical_loads_kw[ts] for ts in p.time_steps_without_grid) - sum( p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cop["ExistingChiller"][ts] for ts in p.time_steps) # tech electric loads from thermal techs - + sum(m[:dvCoolingProduction][t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp), ts in p.time_steps) - + sum(m[:dvHeatingProduction][t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater, ts in p.time_steps) + + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t, ts] / p.cooling_cop[t][ts] for s in 1:p.n_scenarios, t in setdiff(p.techs.cooling,p.techs.ghp), ts in p.time_steps) + + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, t, q, ts] / p.heating_cop[t][ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.electric_heater, ts in p.time_steps) + sum(p.ghp_electric_consumption_kw[g,ts] * m[:binGHP][g] for g in p.ghp_options, ts in p.time_steps) ) ) diff --git a/src/constraints/steam_turbine_constraints.jl b/src/constraints/steam_turbine_constraints.jl index 22ac72909..e085f52f9 100644 --- a/src/constraints/steam_turbine_constraints.jl +++ b/src/constraints/steam_turbine_constraints.jl @@ -4,41 +4,41 @@ function steam_turbine_thermal_input(m, p; _n="") # This constraint is already included in storage_constraints.jl if HotThermalStorage and SteamTurbine are considered that also includes dvProductionToStorage["HotThermalStorage"] in LHS if isempty(p.s.storage.types.hot) - @constraint(m, SupplySteamTurbineProductionLimit[t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, SupplySteamTurbineProductionLimit[s in 1:p.n_scenarios, t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) else - @constraint(m, SupplySteamTurbineProductionLimit[t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] + sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, SupplySteamTurbineProductionLimit[s in 1:p.n_scenarios, t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) end end function steam_turbine_production_constraints(m, p; _n="") # Constraint Steam Turbine Thermal Production - @constraint(m, SteamTurbineThermalProductionCon[t in p.techs.steam_turbine, ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for q in p.heating_loads) == p.s.steam_turbine.thermal_produced_to_thermal_consumed_ratio * ( - sum(m[Symbol("dvThermalToSteamTurbine"*_n)][tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads) + - sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + @constraint(m, SteamTurbineThermalProductionCon[s in 1:p.n_scenarios, t in p.techs.steam_turbine, ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] for q in p.heating_loads) == p.s.steam_turbine.thermal_produced_to_thermal_consumed_ratio * ( + sum(m[Symbol("dvThermalToSteamTurbine"*_n)][s,tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads) + + sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) ) ) # Constraint Steam Turbine Electric Production - @constraint(m, SteamTurbineElectricProductionCon[t in p.techs.steam_turbine, ts in p.time_steps], - m[Symbol("dvRatedProduction"*_n)][t,ts] == p.s.steam_turbine.electric_produced_to_thermal_consumed_ratio * ( - sum(m[Symbol("dvThermalToSteamTurbine"*_n)][tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads) + - sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + @constraint(m, SteamTurbineElectricProductionCon[s in 1:p.n_scenarios, t in p.techs.steam_turbine, ts in p.time_steps], + m[Symbol("dvRatedProduction"*_n)][s,t,ts] == p.s.steam_turbine.electric_produced_to_thermal_consumed_ratio * ( + sum(m[Symbol("dvThermalToSteamTurbine"*_n)][s,tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads) + + sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) ) ) if p.s.steam_turbine.charge_storage_only #assume hot water TES first, hot sensible TES otherwise. if "HotThermalStorage" in p.s.storage.types.hot - @constraint(m, TurbineToStorageOnly[t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] == m[Symbol("dvHeatToStorage"*_n)]["HotThermalStorage",t,q,ts] + @constraint(m, TurbineToStorageOnly[s in 1:p.n_scenarios, t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] == m[Symbol("dvHeatToStorage"*_n)][s,"HotThermalStorage",t,q,ts] ) elseif "HighTempThermalStorage" in p.s.storage.types.hot - @constraint(m, TurbineToStorageOnly[t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] == m[Symbol("dvHeatToStorage"*_n)]["HighTempThermalStorage",t,q,ts] + @constraint(m, TurbineToStorageOnly[s in 1:p.n_scenarios, t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] == m[Symbol("dvHeatToStorage"*_n)][s,"HighTempThermalStorage",t,q,ts] ) else @warn "SteamTurbine.charge_storage_only is set to True, but no hot storage technologies exist." @@ -46,10 +46,12 @@ function steam_turbine_production_constraints(m, p; _n="") end if !p.s.steam_turbine.can_waste_heat - for t in p.techs.steam_turbine - for q in p.heating_loads - for ts in p.time_steps - fix(m[Symbol("dvProductionToWaste"*_n)][t,q,ts] , 0.0, force=true) + for s in 1:p.n_scenarios + for t in p.techs.steam_turbine + for q in p.heating_loads + for ts in p.time_steps + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] , 0.0, force=true) + end end end end @@ -62,7 +64,7 @@ function add_steam_turbine_constraints(m, p; _n="") steam_turbine_thermal_input(m, p; _n) m[:TotalSteamTurbinePerUnitProdOMCosts] = @expression(m, p.third_party_factor * p.pwf_om * - sum(p.s.steam_turbine.om_cost_per_kwh * p.hours_per_time_step * - m[:dvRatedProduction][t, ts] for t in p.techs.steam_turbine, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.s.steam_turbine.om_cost_per_kwh * p.hours_per_time_step * + m[:dvRatedProduction][s, t, ts] for s in 1:p.n_scenarios, t in p.techs.steam_turbine, ts in p.time_steps) ) end \ No newline at end of file diff --git a/src/constraints/storage_constraints.jl b/src/constraints/storage_constraints.jl index e8fb60940..d61210fc3 100644 --- a/src/constraints/storage_constraints.jl +++ b/src/constraints/storage_constraints.jl @@ -33,7 +33,6 @@ function add_storage_size_constraints(m, p, b; _n="") ) end - end @@ -41,32 +40,32 @@ function add_general_storage_dispatch_constraints(m, p, b; _n="") # Constraint (4a): initial and final state of charge if hasproperty(p.s.storage.attr[b], :optimize_soc_init_fraction) && p.s.storage.attr[b].optimize_soc_init_fraction @info "\nOptimizing "*b*" inital SOC and constraining initial SOC = final SOC. soc_init_fraction will not apply.\n" - @constraint(m, - m[Symbol("dvStoredEnergy"*_n)][b, 0] == m[:dvStoredEnergy][b, maximum(p.time_steps)] + @constraint(m, [s in 1:p.n_scenarios], + m[Symbol("dvStoredEnergy"*_n)][s, b, 0] == m[:dvStoredEnergy][s, b, maximum(p.time_steps)] ) else - @constraint(m, - m[Symbol("dvStoredEnergy"*_n)][b, 0] == p.s.storage.attr[b].soc_init_fraction * m[Symbol("dvStorageEnergy"*_n)][b] + @constraint(m, [s in 1:p.n_scenarios], + m[Symbol("dvStoredEnergy"*_n)][s, b, 0] == p.s.storage.attr[b].soc_init_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) # TODO: constrain final soc to equal initial soc even when not optimized (ran into feasibility issues) # @constraint(m, # m[Symbol("dvStoredEnergy"*_n)][b, maximum(p.time_steps)] == p.s.storage.attr[b].soc_init_fraction * m[Symbol("dvStorageEnergy"*_n)][b] - # ) + # ) end #Constraint (4n): State of charge upper bound is storage system size - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStoredEnergy"*_n)][b,ts] <= m[Symbol("dvStorageEnergy"*_n)][b] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][s, b, ts] <= m[Symbol("dvStorageEnergy"*_n)][b] ) # Constraint (4j): Minimum state of charge - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStoredEnergy"*_n)][b, ts] >= p.s.storage.attr[b].soc_min_fraction * m[Symbol("dvStorageEnergy"*_n)][b] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][s, b, ts] >= p.s.storage.attr[b].soc_min_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) #Constraint (4j): Dispatch from storage is no greater than power capacity - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b, ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts] ) end @@ -74,66 +73,66 @@ end function add_elec_storage_dispatch_constraints(m, p, b; _n="") - # Constraint (4g)-1: state-of-charge for electrical storage - with grid - @constraint(m, [ts in p.time_steps_with_grid], - m[Symbol("dvStoredEnergy"*_n)][b, ts] == m[Symbol("dvStoredEnergy"*_n)][b, ts-1] + p.hours_per_time_step * ( - sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) - + p.s.storage.attr[b].grid_charge_efficiency * m[Symbol("dvGridToStorage"*_n)][b, ts] - - m[Symbol("dvDischargeFromStorage"*_n)][b,ts] / p.s.storage.attr[b].discharge_efficiency + # Constraint (4g)-1: state-of-charge for electrical storage - with grid + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + m[Symbol("dvStoredEnergy"*_n)][s, b, ts] == m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] + p.hours_per_time_step * ( + sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for t in p.techs.elec) + + p.s.storage.attr[b].grid_charge_efficiency * m[Symbol("dvGridToStorage"*_n)][s, b, ts] + - m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] / p.s.storage.attr[b].discharge_efficiency ) - ) - # Constraint (4g)-2: state-of-charge for electrical storage - no grid - @constraint(m, [ts in p.time_steps_without_grid], - m[Symbol("dvStoredEnergy"*_n)][b, ts] == m[Symbol("dvStoredEnergy"*_n)][b, ts-1] + p.hours_per_time_step * ( - sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for t in p.techs.elec) - - m[Symbol("dvDischargeFromStorage"*_n)][b, ts] / p.s.storage.attr[b].discharge_efficiency + ) + # Constraint (4g)-2: state-of-charge for electrical storage - no grid + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + m[Symbol("dvStoredEnergy"*_n)][s, b, ts] == m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] + p.hours_per_time_step * ( + sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] for t in p.techs.elec) + - m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts] / p.s.storage.attr[b].discharge_efficiency ) ) - # Constraint (4h): prevent simultaneous charge and discharge by limitting charging alone to not make the SOC exceed 100% + # Constraint (4h): prevent simultaneous charge and discharge by limitting charging alone to not make the SOC exceed 100% # (4h)-1: with grid - @constraint(m, [ts in p.time_steps_with_grid], - m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][b, ts-1] + p.hours_per_time_step * ( - sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) - + p.s.storage.attr[b].grid_charge_efficiency * m[Symbol("dvGridToStorage"*_n)][b, ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] + p.hours_per_time_step * ( + sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for t in p.techs.elec) + + p.s.storage.attr[b].grid_charge_efficiency * m[Symbol("dvGridToStorage"*_n)][s, b, ts] ) - ) - # (4h)-2: no grid - @constraint(m, [ts in p.time_steps_without_grid], - m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][b, ts-1] + p.hours_per_time_step * ( - sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for t in p.techs.elec) + ) + # (4h)-2: no grid + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] + p.hours_per_time_step * ( + sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] for t in p.techs.elec) ) ) - # Constraint (4i)-1: Dispatch to electrical storage is no greater than power capacity - @constraint(m, [ts in p.time_steps], + # Constraint (4i)-1: Dispatch to electrical storage is no greater than power capacity + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStoragePower"*_n)][b] >= - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][b, ts] + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][s, b, ts] ) - - #Constraint (4k)-alt: Dispatch to and from electrical storage is no greater than power capacity - @constraint(m, [ts in p.time_steps_with_grid], - m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b, ts] + - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][b, ts] + + #Constraint (4k)-alt: Dispatch to and from electrical storage is no greater than power capacity + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts] + + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for t in p.techs.elec) + m[Symbol("dvGridToStorage"*_n)][s, b, ts] ) - #Constraint (4l)-alt: Dispatch from electrical storage is no greater than power capacity (no grid connection) - @constraint(m, [ts in p.time_steps_without_grid], - m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b,ts] + - sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for t in p.techs.elec) - ) - + #Constraint (4l)-alt: Dispatch from electrical storage is no greater than power capacity (no grid connection) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_without_grid], + m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] + + sum(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] for t in p.techs.elec) + ) # Remove grid-to-storage as an option if option to grid charge is turned off if !(p.s.storage.attr[b].can_grid_charge) - for ts in p.time_steps_with_grid - fix(m[Symbol("dvGridToStorage"*_n)][b, ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps_with_grid + fix(m[Symbol("dvGridToStorage"*_n)][s, b, ts], 0.0, force=true) end end + if p.s.storage.attr[b].minimum_avg_soc_fraction > 0 - avg_soc = sum(m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) / - (8760. / p.hours_per_time_step) - @constraint(m, avg_soc >= p.s.storage.attr[b].minimum_avg_soc_fraction * + @constraint(m, [s in 1:p.n_scenarios], + sum(m[Symbol("dvStoredEnergy"*_n)][s, b, ts] for ts in p.time_steps) / + (8760. / p.hours_per_time_step) >= p.s.storage.attr[b].minimum_avg_soc_fraction * sum(m[Symbol("dvStorageEnergy"*_n)][b]) ) end @@ -148,88 +147,85 @@ function add_hot_thermal_storage_dispatch_constraints(m, p, b; _n="") # Constraint (4f)-1b: SteamTurbineTechs if !isempty(p.techs.steam_turbine) - @constraint(m, [t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) - @constraint(m, [q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] <= m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] + @constraint(m, [s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] <= m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] ) if !p.s.storage.attr[b].can_supply_steam_turbine - for q in p.heating_loads - for ts in p.time_steps - fix(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps + fix(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts], 0.0, force=true) end elseif p.s.storage.attr[b].supply_turbine_only - @constraint(m, [q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] == m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] + @constraint(m, [s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] == m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] ) - for t in p.techs.steam_turbine - for ts in p.time_steps, q in p.heating_loads - fix(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, t in p.techs.steam_turbine, ts in p.time_steps, q in p.heating_loads + fix(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts], 0.0, force=true) end end end # Constraint (4j)-1: Reconcile state-of-charge for (hot) thermal storage + # Note: All thermal variables now have scenario indexing for OUU compatibility if b != "HighTempThermalStorage" - @constraint(m, [b in setdiff(p.s.storage.types.hot, ["HighTempThermalStorage"]), ts in p.time_steps], - m[Symbol("dvStoredEnergy"*_n)][b,ts] == m[Symbol("dvStoredEnergy"*_n)][b,ts-1] + p.hours_per_time_step * ( - p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) - - sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for q in p.heating_loads) / p.s.storage.attr[b].discharge_efficiency - + @constraint(m, [s in 1:p.n_scenarios, b in setdiff(p.s.storage.types.hot, ["HighTempThermalStorage"]), ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][s, b,ts] == m[Symbol("dvStoredEnergy"*_n)][s, b,ts-1] + p.hours_per_time_step * ( + p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) - + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for q in p.heating_loads) / p.s.storage.attr[b].discharge_efficiency - p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) ) else - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStoredEnergy"*_n)][b,ts] == m[Symbol("dvStoredEnergy"*_n)][b,ts-1] + (1/p.s.settings.time_steps_per_hour) * ( - p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) - - sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for q in p.heating_loads) / p.s.storage.attr[b].discharge_efficiency - - p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStoredEnergy"*_n)][b, ts-1] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][s, b,ts] == m[Symbol("dvStoredEnergy"*_n)][s, b,ts-1] + (1/p.s.settings.time_steps_per_hour) * ( + p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) - + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for q in p.heating_loads) / p.s.storage.attr[b].discharge_efficiency - + p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStoredEnergy"*_n)][s, b, ts-1] ) ) end # Prevent simultaneous charge and discharge by limitting charging alone to not make the SOC exceed 100% - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][b,ts-1] + p.hours_per_time_step * ( - p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][s, b,ts-1] + p.hours_per_time_step * ( + p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) - p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) ) #Constraint (4n)-1: Dispatch to and from thermal storage is no greater than power capacity - @constraint(m, [ts in p.time_steps], + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStoragePower"*_n)][b] >= - sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] + - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] + + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp)) for q in p.heating_loads) ) if b == "HighTempThermalStorage" - @constraint(m, [ts in p.time_steps], + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStorageEnergy"*_n)][b] / p.s.storage.attr[b].num_charge_hours >= - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) ) - @constraint(m, [ts in p.time_steps], + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStorageEnergy"*_n)]["HighTempThermalStorage"] / p.s.storage.attr["HighTempThermalStorage"].num_discharge_hours >= - sum(m[Symbol("dvHeatFromStorage"*_n)]["HighTempThermalStorage",q,ts] + sum(m[Symbol("dvHeatFromStorage"*_n)][s,"HighTempThermalStorage",q,ts] for q in p.heating_loads) ) end # TODO missing thermal storage constraints from API ??? # Constraint (4o): Discharge from storage is equal to sum of heat from storage for all qualities - @constraint(m, [ts in p.time_steps], - m[Symbol("dvDischargeFromStorage"*_n)][b,ts] == - sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for q in p.heating_loads) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] == + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for q in p.heating_loads) ) #Do not allow GHP to charge storage if !isempty(p.techs.ghp) - for t in p.techs.ghp, q in p.heating_loads, ts in p.time_steps - fix(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.techs.ghp, q in p.heating_loads, ts in p.time_steps + fix(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts], 0.0, force=true) end end @@ -243,12 +239,12 @@ function add_hot_thermal_storage_dispatch_constraints(m, p, b; _n="") 100 * maximum(sum(p.heating_loads_kw[q][ts] for q in p.heating_loads) for ts in p.time_steps) ) - @constraint(m, HighTempStorageChargeMax[ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) <= + @constraint(m, HighTempStorageChargeMax[s in 1:p.n_scenarios, ts in p.time_steps], + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for t in union(p.techs.heating, p.techs.chp), q in p.heating_loads) <= max_storage_power * m[Symbol("binStorageCharge"*_n)][b,ts] ) - @constraint(m, HighTempStorageDischargeMax[ts in p.time_steps], - sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for q in p.heating_loads) <= + @constraint(m, HighTempStorageDischargeMax[s in 1:p.n_scenarios, ts in p.time_steps], + sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for q in p.heating_loads) <= max_storage_power * m[Symbol("binStorageDischarge"*_n)][b,ts] ) @constraint(m, HighTempStorageFlowDirection[ts in p.time_steps], @@ -262,39 +258,39 @@ function add_cold_thermal_storage_dispatch_constraints(m, p, b; _n="") # Constraint (4f)-2: (Cold) Thermal production sent to storage or grid must be less than technology's rated production if !isempty(p.techs.cooling) - @constraint(m, CoolingTechProductionFlowCon[t in p.techs.cooling, ts in p.time_steps], - m[Symbol("dvProductionToStorage"*_n)][b,t,ts] <= - m[Symbol("dvCoolingProduction"*_n)][t,ts] - ) - end - + @constraint(m, CoolingTechProductionFlowCon[s in 1:p.n_scenarios, t in p.techs.cooling, ts in p.time_steps], + m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] <= + m[Symbol("dvCoolingProduction"*_n)][s,t,ts] + ) + end + # Constraint (4j)-2: Reconcile state-of-charge for (cold) thermal storage - @constraint(m, ColdTESInventoryCon[ts in p.time_steps], - m[Symbol("dvStoredEnergy"*_n)][b,ts] == m[Symbol("dvStoredEnergy"*_n)][b,ts-1] + p.hours_per_time_step * ( - sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for t in p.techs.cooling) - - m[Symbol("dvDischargeFromStorage"*_n)][b,ts]/p.s.storage.attr[b].discharge_efficiency - + @constraint(m, ColdTESInventoryCon[s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoredEnergy"*_n)][s, b,ts] == m[Symbol("dvStoredEnergy"*_n)][s, b,ts-1] + p.hours_per_time_step * ( + sum(p.s.storage.attr[b].charge_efficiency * m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] for t in p.techs.cooling) - + m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts]/p.s.storage.attr[b].discharge_efficiency - p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) ) # Prevent simultaneous charge and discharge by limitting charging alone to not make the SOC exceed 100% - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][b,ts-1] + p.hours_per_time_step * ( - p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for t in p.techs.cooling) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStorageEnergy"*_n)][b] >= m[Symbol("dvStoredEnergy"*_n)][s, b,ts-1] + p.hours_per_time_step * ( + p.s.storage.attr[b].charge_efficiency * sum(m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] for t in p.techs.cooling) - p.s.storage.attr[b].thermal_decay_rate_fraction * m[Symbol("dvStorageEnergy"*_n)][b] ) ) #Constraint (4n)-2: Dispatch to and from thermal storage is no greater than power capacity - @constraint(m, [ts in p.time_steps], - m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][b,ts] + - sum(m[Symbol("dvProductionToStorage"*_n)][b,t,ts] for t in p.techs.cooling) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvStoragePower"*_n)][b] >= m[Symbol("dvDischargeFromStorage"*_n)][s, b,ts] + + sum(m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts] for t in p.techs.cooling) ) #Do not allow GHP to charge storage if !isempty(p.techs.ghp) - for t in p.techs.ghp, ts in p.time_steps - fix(m[Symbol("dvProductionToStorage"*_n)][b,t,ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.techs.ghp, ts in p.time_steps + fix(m[Symbol("dvProductionToStorage"*_n)][s, b,t,ts], 0.0, force=true) end end end @@ -302,9 +298,9 @@ end function add_storage_sum_grid_constraints(m, p; _n="") ##Constraint (8c): Grid-to-storage no greater than grid purchases - @constraint(m, [ts in p.time_steps_with_grid], - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) >= - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps_with_grid], + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) >= + sum(m[Symbol("dvGridToStorage"*_n)][s, b, ts] for b in p.s.storage.types.elec) ) end @@ -329,40 +325,38 @@ function add_hot_tes_flow_restrictions!(m, p, b) end if !isempty(incompatible_loads_served) @warn "Technology "*t*" is ineligible to serve storage system "*b*" due to the following incompatible loads served "*string(incompatible_loads_served) - for q in p.heating_loads_served_by_tes[b] - for ts in p.time_steps - fix(m[:dvHeatToStorage][b,t,q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, q in p.heating_loads_served_by_tes[b], ts in p.time_steps + fix(m[:dvHeatToStorage][s,b,t,q,ts], 0.0, force=true) end end end #If load isn't served by storage, all charge or discharge flows of that quality heat are zero if !isempty(setdiff(p.heating_loads, p.heating_loads_served_by_tes[b])) - @constraint(m, [t in union(p.techs.heating, p.techs.chp), + @constraint(m, [s in 1:p.n_scenarios, t in union(p.techs.heating, p.techs.chp), q in setdiff(p.heating_loads, p.heating_loads_served_by_tes[b]), ts in p.time_steps], - m[:dvHeatToStorage][b,t,q,ts] == 0 + m[:dvHeatToStorage][s,b,t,q,ts] == 0 ) - @constraint(m, [q in setdiff(p.heating_loads, p.heating_loads_served_by_tes[b]), - ts in p.time_steps], m[:dvHeatFromStorage][b,q,ts] == 0 + @constraint(m, [s in 1:p.n_scenarios, q in setdiff(p.heating_loads, p.heating_loads_served_by_tes[b]), + ts in p.time_steps], m[:dvHeatFromStorage][s,b,q,ts] == 0 ) end # If a heating load is served by a storage vehicle, only allow charge from compatible techs. otherwise, allow no charge for that heat quality. if "DomesticHotWater" in p.heating_loads_served_by_tes[b] && !isempty(setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_dhw)) - @constraint(m, [t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_dhw), ts in p.time_steps], - m[:dvHeatToStorage][b,t,"DomesticHotWater",ts] == 0 + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_dhw), ts in p.time_steps], + m[:dvHeatToStorage][s,b,t,"DomesticHotWater",ts] == 0 ) end if "SpaceHeating" in p.heating_loads_served_by_tes[b] && !isempty(setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_space_heating)) - @constraint(m, [t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_space_heating), ts in p.time_steps], - m[:dvHeatToStorage][b,t,"SpaceHeating",ts] == 0 + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_space_heating), ts in p.time_steps], + m[:dvHeatToStorage][s,b,t,"SpaceHeating",ts] == 0 ) end if "ProcessHeat" in p.heating_loads_served_by_tes[b] && !isempty(setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_process_heat)) - @constraint(m, [t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_process_heat), ts in p.time_steps], - m[:dvHeatToStorage][b,t,"ProcessHeat",ts] == 0 + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.can_serve_process_heat), ts in p.time_steps], + m[:dvHeatToStorage][s,b,t,"ProcessHeat",ts] == 0 ) end end \ No newline at end of file diff --git a/src/constraints/tech_constraints.jl b/src/constraints/tech_constraints.jl index 448d799e6..fc2b1154c 100644 --- a/src/constraints/tech_constraints.jl +++ b/src/constraints/tech_constraints.jl @@ -48,23 +48,21 @@ function add_tech_size_constraints(m, p; _n="") ) ## Constraint (7d): Non-turndown technologies are always at rated production - @constraint(m, [t in p.techs.no_turndown, ts in p.time_steps], - m[Symbol("dvRatedProduction"*_n)][t,ts] == m[Symbol("dvSize"*_n)][t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.no_turndown, ts in p.time_steps], + m[Symbol("dvRatedProduction"*_n)][s, t,ts] == m[Symbol("dvSize"*_n)][t] ) ##Constraint (7e): SteamTurbine is not in techs.no_turndown OR techs.segmented, so handle electric production to dvSize constraint if !isempty(p.techs.steam_turbine) - @constraint(m, [t in p.techs.steam_turbine, ts in p.time_steps], - m[Symbol("dvRatedProduction"*_n)][t,ts] <= m[:dvSize][t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.steam_turbine, ts in p.time_steps], + m[Symbol("dvRatedProduction"*_n)][s, t,ts] <= m[:dvSize][t] ) end end function add_no_curtail_constraints(m, p; _n="") - for t in p.techs.no_curtail - for ts in p.time_steps - fix(m[Symbol("dvCurtail"*_n)][t, ts] , 0.0, force=true) - end + for s in 1:p.n_scenarios, t in p.techs.no_curtail, ts in p.time_steps + fix(m[Symbol("dvCurtail"*_n)][s, t, ts] , 0.0, force=true) end end diff --git a/src/constraints/thermal_tech_constraints.jl b/src/constraints/thermal_tech_constraints.jl index 2f6f227c1..842eb39ea 100644 --- a/src/constraints/thermal_tech_constraints.jl +++ b/src/constraints/thermal_tech_constraints.jl @@ -2,21 +2,21 @@ function add_boiler_tech_constraints(m, p; _n="") - m[:TotalBoilerFuelCosts] = @expression(m, sum(p.pwf_fuel[t] * - sum(m[:dvFuelUsage][t, ts] * p.fuel_cost_per_kwh[t][ts] for ts in p.time_steps) - for t in p.techs.boiler) + m[:TotalBoilerFuelCosts] = @expression(m, sum(p.scenario_probabilities[s] * p.pwf_fuel[t] * + sum(m[:dvFuelUsage][s, t, ts] * p.fuel_cost_per_kwh[t][ts] for ts in p.time_steps) + for s in 1:p.n_scenarios, t in p.techs.boiler) ) # Constraint (1e): Total Fuel burn for Boiler - @constraint(m, BoilerFuelTrackingCon[t in p.techs.boiler, ts in p.time_steps], - m[:dvFuelUsage][t,ts] == p.hours_per_time_step * ( - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) / p.boiler_efficiency[t] + @constraint(m, BoilerFuelTrackingCon[s in 1:p.n_scenarios, t in p.techs.boiler, ts in p.time_steps], + m[:dvFuelUsage][s, t,ts] == p.hours_per_time_step * ( + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) / p.boiler_efficiency[t] ) ) if "Boiler" in p.techs.boiler # ExistingBoiler does not have om_cost_per_kwh m[:TotalBoilerPerUnitProdOMCosts] = @expression(m, p.third_party_factor * p.pwf_om * - sum(p.s.boiler.om_cost_per_kwh * p.hours_per_time_step * - m[Symbol("dvHeatingProduction"*_n)]["Boiler",q,ts] for q in p.heating_loads, ts in p.time_steps) + sum(p.scenario_probabilities[s] * p.s.boiler.om_cost_per_kwh * p.hours_per_time_step * + m[Symbol("dvHeatingProduction"*_n)][s,"Boiler",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps) ) else m[:TotalBoilerPerUnitProdOMCosts] = 0.0 @@ -27,64 +27,64 @@ function add_heating_tech_constraints(m, p; _n="") # Constraint (7_heating_flow): Flows to Steam turbine, waste, and turbine must be less than or equal to total production if !isempty(p.techs.steam_turbine) if !isempty(p.s.storage.types.hot) - @constraint(m, [t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) if !isempty(setdiff(union(p.techs.heating, p.techs.chp),p.techs.can_supply_steam_turbine)) - @constraint(m, [t in setdiff(union(p.techs.heating,p.techs.chp),p.techs.can_supply_steam_turbine), q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(union(p.techs.heating,p.techs.chp),p.techs.can_supply_steam_turbine), q in p.heating_loads, ts in p.time_steps], + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) end else - @constraint(m, [t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) if !isempty(setdiff(union(p.techs.heating, p.techs.chp),p.techs.can_supply_steam_turbine)) - @constraint(m, [t in setdiff(union(p.techs.heating,p.techs.chp),p.techs.can_supply_steam_turbine), q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(union(p.techs.heating,p.techs.chp),p.techs.can_supply_steam_turbine), q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) end end else if !isempty(p.s.storage.types.hot) - @constraint(m, [t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= - m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], + sum(m[Symbol("dvHeatToStorage"*_n)][s,b,t,q,ts] for b in p.s.storage.types.hot) + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= + m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) else - @constraint(m, [t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvProductionToWaste"*_n)][t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][t,q,ts] + @constraint(m, [s in 1:p.n_scenarios, t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] <= m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] ) end end # Constraint (7_heating_prod_size): Production limit based on size for non-electricity-producing heating techs if !isempty(setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp))) - @constraint(m, [t in setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp)), ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) <= p.heating_cf[t][ts] * m[Symbol("dvSize"*_n)][t] + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp)), ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) <= p.heating_cf[t][ts] * m[Symbol("dvSize"*_n)][t] ) end # Constraint (7_heating_load_compatability): Set production variables for incompatible heat loads to zero for t in setdiff(union(p.techs.heating, p.techs.chp), p.techs.ghp) if !(t in p.techs.can_serve_space_heating) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"SpaceHeating",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"SpaceHeating",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][s,t,"SpaceHeating",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,"SpaceHeating",ts], 0.0, force=true) end end if !(t in p.techs.can_serve_dhw) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][s,t,"DomesticHotWater",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,"DomesticHotWater",ts], 0.0, force=true) end end if !(t in p.techs.can_serve_process_heat) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"ProcessHeat",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"ProcessHeat",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][s,t,"ProcessHeat",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,"ProcessHeat",ts], 0.0, force=true) end end end @@ -94,12 +94,12 @@ function add_heating_tech_constraints(m, p; _n="") if p.s.electric_heater.charge_storage_only #assume sensible TES first, and hot water otherwise. if "HighTempThermalStorage" in p.s.storage.types.hot - @constraint(m, ElectricHeaterToStorageOnly[q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["ElectricHeater",q,ts] == m[Symbol("dvHeatToStorage"*_n)]["HighTempThermalStorage","ElectricHeater",q,ts] + @constraint(m, ElectricHeaterToStorageOnly[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"ElectricHeater",q,ts] == m[Symbol("dvHeatToStorage"*_n)][s,"HighTempThermalStorage","ElectricHeater",q,ts] ) elseif "HotThermalStorage" in p.s.storage.types.hot - @constraint(m, ElectricHeaterToStorageOnly[q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["ElectricHeater",q,ts] == m[Symbol("dvHeatToStorage"*_n)]["HotThermalStorage","ElectricHeater",q,ts] + @constraint(m, ElectricHeaterToStorageOnly[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"ElectricHeater",q,ts] == m[Symbol("dvHeatToStorage"*_n)][s,"HotThermalStorage","ElectricHeater",q,ts] ) else @warn "ElectricHeater.charge_storage_only is set to True, but no hot storage technologies exist." @@ -108,28 +108,26 @@ function add_heating_tech_constraints(m, p; _n="") end if "CST" in p.techs.electric_heater - @constraint(m, CSTHeatProduction[ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)]["CST",q,ts] for q in p.heating_loads) == p.heating_cf["CST"][ts] * m[Symbol("dvSize"*_n)]["CST"] + @constraint(m, CSTHeatProduction[s in 1:p.n_scenarios, ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,"CST",q,ts] for q in p.heating_loads) == p.heating_cf["CST"][ts] * m[Symbol("dvSize"*_n)]["CST"] ) if p.s.cst.charge_storage_only #assume sensible TES first, and hot water otherwise. if "HighTempThermalStorage" in p.s.storage.types.hot - @constraint(m, CSTToStorageOnly[q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["CST",q,ts] == m[Symbol("dvProductionToWaste"*_n)]["CST",q,ts] + m[Symbol("dvHeatToStorage"*_n)]["HighTempThermalStorage","CST",q,ts] + @constraint(m, CSTToStorageOnly[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"CST",q,ts] == m[Symbol("dvProductionToWaste"*_n)][s,"CST",q,ts] + m[Symbol("dvHeatToStorage"*_n)][s,"HighTempThermalStorage","CST",q,ts] ) elseif "HotThermalStorage" in p.s.storage.types.hot - @constraint(m, CSTToStorageOnly[q in p.heating_loads, ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["CST",q,ts] == m[Symbol("dvProductionToWaste"*_n)]["CST",q,ts] + m[Symbol("dvHeatToStorage"*_n)]["HotThermalStorage","CST",q,ts] + @constraint(m, CSTToStorageOnly[s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"CST",q,ts] == m[Symbol("dvProductionToWaste"*_n)][s,"CST",q,ts] + m[Symbol("dvHeatToStorage"*_n)][s,"HotThermalStorage","CST",q,ts] ) else @warn "CST.charge_storage_only is set to True, but no hot storage technologies exist." end end if !p.s.cst.can_waste_heat - for q in p.heating_loads - for ts in p.time_steps - fix(m[Symbol("dvProductionToWaste"*_n)]["CST",q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps + fix(m[Symbol("dvProductionToWaste"*_n)][s,"CST",q,ts], 0.0, force=true) end end end @@ -137,17 +135,15 @@ function add_heating_tech_constraints(m, p; _n="") # Enforce no waste heat for any technology that isn't both electricity- and heat-producing for t in setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp, ["CST"])) - for q in p.heating_loads - for ts in p.time_steps - fix(m[Symbol("dvProductionToWaste"*_n)][t,q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts], 0.0, force=true) end end end function add_heating_cooling_constraints(m, p; _n="") - @constraint(m, [t in setdiff(intersect(p.techs.cooling, p.techs.heating), p.techs.ghp), ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) / p.heating_cf[t][ts] + m[Symbol("dvCoolingProduction"*_n)][t,ts] / p.cooling_cf[t][ts] <= m[Symbol("dvSize"*_n)][t] + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(intersect(p.techs.cooling, p.techs.heating), p.techs.ghp), ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) / p.heating_cf[t][ts] + m[Symbol("dvCoolingProduction"*_n)][s,t,ts] / p.cooling_cf[t][ts] <= m[Symbol("dvSize"*_n)][t] ) end @@ -156,9 +152,9 @@ function add_ashp_force_in_constraints(m, p; _n="") if "ASHPSpaceHeater" in p.techs.ashp if p.s.ashp.force_into_system for t in setdiff(p.techs.can_serve_space_heating, ["ASHPSpaceHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"SpaceHeating",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"SpaceHeating",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][s,t,"SpaceHeating",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,"SpaceHeating",ts], 0.0, force=true) end end elseif p.s.ashp.force_dispatch @@ -194,8 +190,8 @@ function add_ashp_force_in_constraints(m, p; _n="") m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] <= max_sh_size_bigM * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] ) #Enforce dispatch: output = system size - (overage) - @constraint(m, [ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["ASHPSpaceHeater","SpaceHeating",ts] / p.heating_cf["ASHPSpaceHeater"][ts] + m[Symbol("dvCoolingProduction"*_n)]["ASHPSpaceHeater",ts] / p.cooling_cf["ASHPSpaceHeater"][ts] >= m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"] - m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] + (p.heating_loads_kw["SpaceHeating"][ts] / p.heating_cf["ASHPSpaceHeater"][ts] + p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cf["ASHPSpaceHeater"][ts] ) * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"ASHPSpaceHeater","SpaceHeating",ts] / p.heating_cf["ASHPSpaceHeater"][ts] + m[Symbol("dvCoolingProduction"*_n)][s,"ASHPSpaceHeater",ts] / p.cooling_cf["ASHPSpaceHeater"][ts] >= m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"] - m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] + (p.heating_loads_kw["SpaceHeating"][ts] / p.heating_cf["ASHPSpaceHeater"][ts] + p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cf["ASHPSpaceHeater"][ts] ) * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] ) else # binary variable enforcement for size >= load @@ -219,8 +215,8 @@ function add_ashp_force_in_constraints(m, p; _n="") m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] <= max_sh_size_bigM * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] ) #Enforce dispatch: output = system size - (overage) - @constraint(m, [ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["ASHPSpaceHeater","SpaceHeating",ts] >= p.heating_cf["ASHPSpaceHeater"][ts]*m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"] - m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] + p.heating_loads_kw["SpaceHeating"][ts] * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"ASHPSpaceHeater","SpaceHeating",ts] >= p.heating_cf["ASHPSpaceHeater"][ts]*m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"] - m[Symbol("dvASHPSHSizeTimesExcess"*_n)][ts] + p.heating_loads_kw["SpaceHeating"][ts] * m[Symbol("binASHPSHSizeExceedsThermalLoad"*_n)][ts] ) end end @@ -228,8 +224,8 @@ function add_ashp_force_in_constraints(m, p; _n="") if "ASHPSpaceHeater" in p.techs.cooling && p.s.ashp.force_into_system for t in setdiff(p.techs.cooling, ["ASHPSpaceHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvCoolingProduction"*_n)][t,ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvCoolingProduction"*_n)][s,t,ts], 0.0, force=true) end end end @@ -237,9 +233,9 @@ function add_ashp_force_in_constraints(m, p; _n="") if "ASHPWaterHeater" in p.techs.ashp if p.s.ashp_wh.force_into_system for t in setdiff(p.techs.can_serve_dhw, ["ASHPWaterHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][s,t,"DomesticHotWater",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][s,t,"DomesticHotWater",ts], 0.0, force=true) end end elseif p.s.ashp_wh.force_dispatch @@ -268,8 +264,8 @@ function add_ashp_force_in_constraints(m, p; _n="") m[Symbol("dvASHPWHSizeTimesExcess"*_n)][ts] <= max_wh_size_bigM * m[Symbol("binASHPWHSizeExceedsThermalLoad"*_n)][ts] ) #Enforce dispatch: output = system size - (overage) - @constraint(m, [ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["ASHPWaterHeater","DomesticHotWater",ts] >= p.heating_cf["ASHPWaterHeater"][ts]*m[Symbol("dvSize"*_n)]["ASHPWaterHeater"] - m[Symbol("dvASHPWHSizeTimesExcess"*_n)][ts] + p.heating_loads_kw["DomesticHotWater"][ts] * m[Symbol("binASHPWHSizeExceedsThermalLoad"*_n)][ts] + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)][s,"ASHPWaterHeater","DomesticHotWater",ts] >= p.heating_cf["ASHPWaterHeater"][ts]*m[Symbol("dvSize"*_n)]["ASHPWaterHeater"] - m[Symbol("dvASHPWHSizeTimesExcess"*_n)][ts] + p.heating_loads_kw["DomesticHotWater"][ts] * m[Symbol("binASHPWHSizeExceedsThermalLoad"*_n)][ts] ) end end @@ -282,30 +278,28 @@ function avoided_capex_by_ashp(m, p; _n="") end function no_existing_boiler_production(m, p; _n="") - for ts in p.time_steps - for q in p.heating_loads - fix(m[Symbol("dvHeatingProduction"*_n)]["ExistingBoiler",q,ts], 0.0, force=true) - end + for s in 1:p.n_scenarios, ts in p.time_steps, q in p.heating_loads + fix(m[Symbol("dvHeatingProduction"*_n)][s,"ExistingBoiler",q,ts], 0.0, force=true) end fix(m[Symbol("dvSize"*_n)]["ExistingBoiler"], 0.0, force=true) end function add_cooling_tech_constraints(m, p; _n="") # Constraint (7_cooling_prod_size): Production limit based on size for boiler - @constraint(m, [t in setdiff(p.techs.cooling, p.techs.ghp), ts in p.time_steps_with_grid], - m[Symbol("dvCoolingProduction"*_n)][t,ts] <= m[Symbol("dvSize"*_n)][t] * p.cooling_cf[t][ts] + @constraint(m, [s in 1:p.n_scenarios, t in setdiff(p.techs.cooling, p.techs.ghp), ts in p.time_steps_with_grid], + m[Symbol("dvCoolingProduction"*_n)][s,t,ts] <= m[Symbol("dvSize"*_n)][t] * p.cooling_cf[t][ts] ) # The load balance for cooling is only applied to time_steps_with_grid, so make sure we don't arbitrarily show cooling production for time_steps_without_grid for t in setdiff(p.techs.cooling, p.techs.ghp) - for ts in p.time_steps_without_grid - fix(m[Symbol("dvCoolingProduction"*_n)][t, ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps_without_grid + fix(m[Symbol("dvCoolingProduction"*_n)][s, t, ts], 0.0, force=true) end end end function no_existing_chiller_production(m, p; _n="") - for ts in p.time_steps - fix(m[Symbol("dvCoolingProduction"*_n)]["ExistingChiller",ts], 0.0, force=true) + for s in 1:p.n_scenarios, ts in p.time_steps + fix(m[Symbol("dvCoolingProduction"*_n)][s,"ExistingChiller",ts], 0.0, force=true) end fix(m[Symbol("dvSize"*_n)]["ExistingChiller"], 0.0, force=true) end diff --git a/src/core/bau_inputs.jl b/src/core/bau_inputs.jl index 3464c39de..cbdf7bcea 100644 --- a/src/core/bau_inputs.jl +++ b/src/core/bau_inputs.jl @@ -166,6 +166,21 @@ function BAUInputs(p::REoptInputs) heating_loads_served_by_tes = Dict{String,Array{String,1}}() unavailability = get_unavailability_by_tech(p.s, techs, p.time_steps) + # Production factors: use optimal scenarios for existing techs, deterministic for others + production_factor_by_scenario = Dict{Int, Dict{String, Vector{Float64}}}( + s => Dict{String, Vector{Float64}}( + t => if haskey(p.production_factor_by_scenario[s], t) + # Use optimal scenario production for existing PV/Generator + Float64.(copy(p.production_factor_by_scenario[s][t])) + else + # Deterministic for techs not in optimal (shouldn't happen in BAU) + Float64.([production_factor[t, ts] for ts in p.time_steps]) + end + for t in techs.all + ) + for s in 1:p.n_scenarios + ) + REoptInputs( bau_scenario, techs, @@ -234,7 +249,11 @@ function BAUInputs(p::REoptInputs) heating_loads_served_by_tes, unavailability, absorption_chillers_using_heating_load, - avoided_capex_by_ashp_present_value + avoided_capex_by_ashp_present_value, + p.n_scenarios, + p.scenario_probabilities, + p.loads_kw_by_scenario, + production_factor_by_scenario ) end diff --git a/src/core/bau_scenario.jl b/src/core/bau_scenario.jl index a32b43880..e507056ea 100644 --- a/src/core/bau_scenario.jl +++ b/src/core/bau_scenario.jl @@ -33,7 +33,9 @@ struct BAUScenario <: AbstractScenario cooling_load::CoolingLoad ghp_option_list::Array{Union{GHP, Nothing}, 1} # List of GHP objects (often just 1 element, but can be more) space_heating_thermal_load_reduction_with_ghp_kw::Union{Vector{Float64}, Nothing} - cooling_thermal_load_reduction_with_ghp_kw::Union{Vector{Float64}, Nothing} + cooling_thermal_load_reduction_with_ghp_kw::Union{Vector{Float64}, Nothing} + load_uncertainty::TimeSeriesUncertainty + production_uncertainty::TimeSeriesUncertainty end @@ -129,6 +131,10 @@ function BAUScenario(s::Scenario) =# site = bau_site(s.site) + # BAU scenario should not have uncertainty - use disabled uncertainty structs + load_uncertainty = TimeSeriesUncertainty(enabled=false) + production_uncertainty = TimeSeriesUncertainty(enabled=false) + return BAUScenario( s.settings, site, @@ -150,6 +156,8 @@ function BAUScenario(s::Scenario) s.cooling_load, ghp_option_list, space_heating_thermal_load_reduction_with_ghp_kw, - cooling_thermal_load_reduction_with_ghp_kw + cooling_thermal_load_reduction_with_ghp_kw, + load_uncertainty, + production_uncertainty ) end \ No newline at end of file diff --git a/src/core/electric_load.jl b/src/core/electric_load.jl index 7e01c3889..273f1c523 100644 --- a/src/core/electric_load.jl +++ b/src/core/electric_load.jl @@ -111,7 +111,8 @@ mutable struct ElectricLoad # mutable to adjust (critical_)loads_kw based off o longitude::Real, time_steps_per_hour::Int = 1, operating_reserve_required_fraction::Real = off_grid_flag ? 0.1 : 0.0, # if off grid, 10%, else must be 0% - min_load_met_annual_fraction::Real = off_grid_flag ? 0.99999 : 1.0 # if off grid, 99.999%, else must be 100%. Applied to each time_step as a % of electric load. + min_load_met_annual_fraction::Real = off_grid_flag ? 0.99999 : 1.0, # if off grid, 99.999%, else must be 100%. Applied to each time_step as a % of electric load. + uncertainty::Union{Dict, Nothing} = nothing # OUU parameter - handled at Scenario level, not stored in ElectricLoad ) if off_grid_flag diff --git a/src/core/pv.jl b/src/core/pv.jl index 46f20aade..070b2dbd4 100644 --- a/src/core/pv.jl +++ b/src/core/pv.jl @@ -167,7 +167,8 @@ mutable struct PV <: AbstractTech use_detailed_cost_curve::Bool = false, electric_load_annual_kwh::Real = 0.0, site_land_acres::Union{Real, Nothing} = nothing, - site_roof_squarefeet::Union{Real, Nothing} = nothing + site_roof_squarefeet::Union{Real, Nothing} = nothing, + production_uncertainty::Union{Dict, Nothing} = nothing # OUU parameter - handled at Scenario level, not stored in PV ) # Adjust operating_reserve_required_fraction based on off_grid_flag diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 6af9ef3c1..08e068e06 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -190,33 +190,33 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) for ts in p.time_steps_without_grid - for tier in 1:p.s.electric_tariff.n_energy_tiers - fix(m[:dvGridPurchase][ts, tier] , 0.0, force=true) + for s in 1:p.n_scenarios, tier in 1:p.s.electric_tariff.n_energy_tiers + fix(m[:dvGridPurchase][s, ts, tier] , 0.0, force=true) end - for t in p.s.storage.types.elec - fix(m[:dvGridToStorage][t, ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.s.storage.types.elec + fix(m[:dvGridToStorage][s, t, ts], 0.0, force=true) end if !isempty(p.s.electric_tariff.export_bins) - for t in p.techs.elec, u in p.export_bins_by_tech[t] - fix(m[:dvProductionToGrid][t, u, ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.techs.elec, u in p.export_bins_by_tech[t] + fix(m[:dvProductionToGrid][s, t, u, ts], 0.0, force=true) end end end for b in p.s.storage.types.all if p.s.storage.attr[b].max_kw == 0 || p.s.storage.attr[b].max_kwh == 0 - @constraint(m, [ts in p.time_steps], m[:dvStoredEnergy][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvStoredEnergy][s, b, ts] == 0) @constraint(m, m[:dvStorageEnergy][b] == 0) - @constraint(m, [ts in p.time_steps], m[:dvDischargeFromStorage][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvDischargeFromStorage][s, b, ts] == 0) if b in p.s.storage.types.elec @constraint(m, m[:dvStoragePower][b] == 0) - @constraint(m, [ts in p.time_steps], m[:dvGridToStorage][b, ts] == 0) - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - m[:dvProductionToStorage][b, t, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvGridToStorage][s, b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + m[:dvProductionToStorage][s, b, t, ts] == 0) elseif b in p.s.storage.types.hot - @constraint(m, [q in p.heating_loads, ts in p.time_steps], m[:dvHeatFromStorage][b,q,ts] == 0) - @constraint(m, [t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], m[:dvHeatToStorage][b,t,q,ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, q in p.heating_loads, ts in p.time_steps], m[:dvHeatFromStorage][s, b,q,ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, t in union(p.techs.heating, p.techs.chp), q in p.heating_loads, ts in p.time_steps], m[:dvHeatToStorage][s, b,t,q,ts] == 0) end else add_storage_size_constraints(m, p, b) @@ -564,8 +564,9 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) m[:ObjectivePenalties] += m[:dvComfortLimitViolationCost] # 2. Incentive to keep SOC high if !(isempty(p.s.storage.types.elec)) && p.s.settings.add_soc_incentive + # Expected SOC incentive across scenarios (unified indexing approach) m[:ObjectivePenalties] += -1 * sum( - m[:dvStoredEnergy][b, ts] for b in p.s.storage.types.elec, ts in p.time_steps + p.scenario_probabilities[s] * m[:dvStoredEnergy][s, b, ts] for s in 1:p.n_scenarios, b in p.s.storage.types.elec, ts in p.time_steps ) / (8760. / p.hours_per_time_step) end # 3. Incentive to minimize unserved load in each outage, not just the max over outage start times @@ -641,20 +642,22 @@ end Add JuMP variables to the model. """ function add_variables!(m::JuMP.AbstractModel, p::REoptInputs) + # For OUU modeling, first-stage (sizing) variables are scenario-independent, while second-stage (dispatch) variables have the scenario index + # For non-OUU, dispatch variables will have 1:1 scenario index range (=>1) @variables m begin dvSize[p.techs.all] >= 0 # System Size of Technology t [kW] dvPurchaseSize[p.techs.all] >= 0 # system kW beyond existing_kw that must be purchased - dvGridPurchase[p.time_steps, 1:p.s.electric_tariff.n_energy_tiers] >= 0 # Power from grid dispatched to meet electrical load [kW] - dvRatedProduction[p.techs.all, p.time_steps] >= 0 # Rated production of technology t [kW] - dvCurtail[p.techs.all, p.time_steps] >= 0 # [kW] - dvProductionToStorage[p.s.storage.types.all, union(p.techs.ghp,p.techs.all), p.time_steps] >= 0 # Power from technology t used to charge storage system b [kW] - dvDischargeFromStorage[p.s.storage.types.all, p.time_steps] >= 0 # Power discharged from storage system b [kW] - dvGridToStorage[p.s.storage.types.elec, p.time_steps] >= 0 # Electrical power delivered to storage by the grid [kW] - dvStoredEnergy[p.s.storage.types.all, 0:p.time_steps[end]] >= 0 # State of charge of storage system b + dvGridPurchase[1:p.n_scenarios, p.time_steps, 1:p.s.electric_tariff.n_energy_tiers] >= 0 # Power from grid dispatched to meet electrical load [kW] + dvRatedProduction[1:p.n_scenarios, p.techs.all, p.time_steps] >= 0 # Rated production of technology t [kW] + dvCurtail[1:p.n_scenarios, p.techs.all, p.time_steps] >= 0 # [kW] + dvProductionToStorage[1:p.n_scenarios, p.s.storage.types.all, union(p.techs.ghp,p.techs.all), p.time_steps] >= 0 # Power from technology t used to charge storage system b [kW] + dvDischargeFromStorage[1:p.n_scenarios, p.s.storage.types.all, p.time_steps] >= 0 # Power discharged from storage system b [kW] + dvGridToStorage[1:p.n_scenarios, p.s.storage.types.elec, p.time_steps] >= 0 # Electrical power delivered to storage by the grid [kW] + dvStoredEnergy[1:p.n_scenarios, p.s.storage.types.all, 0:p.time_steps[end]] >= 0 # State of charge of storage system b dvStoragePower[p.s.storage.types.all] >= 0 # Power capacity of storage system b [kW] dvStorageEnergy[p.s.storage.types.all] >= 0 # Energy capacity of storage system b [kWh] - dvPeakDemandTOU[p.ratchets, 1:p.s.electric_tariff.n_tou_demand_tiers] >= 0 # Peak electrical power demand during ratchet r [kW] - dvPeakDemandMonth[p.months, 1:p.s.electric_tariff.n_monthly_demand_tiers] >= 0 # Peak electrical power demand during month m [kW] + dvPeakDemandTOU[1:p.n_scenarios, p.ratchets, 1:p.s.electric_tariff.n_tou_demand_tiers] >= 0 # Peak electrical power demand during ratchet r [kW] + dvPeakDemandMonth[1:p.n_scenarios, p.months, 1:p.s.electric_tariff.n_monthly_demand_tiers] >= 0 # Peak electrical power demand during month m [kW] MinChargeAdder >= 0 binGHP[p.ghp_options], Bin # Can be <= 1 if require_ghp_purchase=0, and is ==1 if require_ghp_purchase=1 end @@ -673,49 +676,49 @@ function add_variables!(m::JuMP.AbstractModel, p::REoptInputs) if !isempty(p.techs.gen) # Problem becomes a MILP @warn "Adding binary variable to model gas generator. Some solvers are very slow with integer variables." @variables m begin - binGenIsOnInTS[p.techs.gen, p.time_steps], Bin # 1 If technology t is operating in time step h; 0 otherwise + binGenIsOnInTS[1:p.n_scenarios, p.techs.gen, p.time_steps], Bin # 1 If technology t is operating in time step h; 0 otherwise end end if !isempty(p.techs.fuel_burning) - @variable(m, dvFuelUsage[p.techs.fuel_burning, p.time_steps] >= 0) # Fuel burned by technology t in each time step [kWh] + @variable(m, dvFuelUsage[1:p.n_scenarios, p.techs.fuel_burning, p.time_steps] >= 0) # Fuel burned by scenario [kWh] end if !isempty(p.s.electric_tariff.export_bins) - @variable(m, dvProductionToGrid[p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps] >= 0) + @variable(m, dvProductionToGrid[1:p.n_scenarios, p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps] >= 0) end if !(p.s.electric_utility.allow_simultaneous_export_import) & !isempty(p.s.electric_tariff.export_bins) @warn "Adding binary variable to prevent simultaneous grid import/export. Some solvers are very slow with integer variables" - @variable(m, binNoGridPurchases[p.time_steps], Bin) + @variable(m, binNoGridPurchases[1:p.n_scenarios, p.time_steps], Bin) end if !isempty(union(p.techs.heating, p.techs.chp)) - @variable(m, dvHeatingProduction[union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) - @variable(m, dvProductionToWaste[union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) + @variable(m, dvHeatingProduction[1:p.n_scenarios, union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) + @variable(m, dvProductionToWaste[1:p.n_scenarios, union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) if !isempty(p.techs.chp) @variables m begin - dvSupplementaryThermalProduction[p.techs.chp, p.time_steps] >= 0 + dvSupplementaryThermalProduction[1:p.n_scenarios, p.techs.chp, p.time_steps] >= 0 dvSupplementaryFiringSize[p.techs.chp] >= 0 #X^{\sigma db}_{t}: System size of CHP with supplementary firing [kW] end end if !isempty(p.s.storage.types.hot) # TODO introduce these as sparse variables, add a set of techs charging storage? - @variable(m, dvHeatToStorage[p.s.storage.types.hot, union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) # Power charged to hot storage b at quality q [kW] - @variable(m, dvHeatFromStorage[p.s.storage.types.hot, p.heating_loads, p.time_steps] >= 0) # Power discharged from hot storage system b for load q [kW] + @variable(m, dvHeatToStorage[1:p.n_scenarios, p.s.storage.types.hot, union(p.techs.heating, p.techs.chp), p.heating_loads, p.time_steps] >= 0) # Power charged to hot storage b at quality q [kW] + @variable(m, dvHeatFromStorage[1:p.n_scenarios, p.s.storage.types.hot, p.heating_loads, p.time_steps] >= 0) # Power discharged from hot storage system b for load q [kW] if !isempty(p.techs.steam_turbine) - @variable(m, dvHeatFromStorageToTurbine[p.s.storage.types.hot, p.heating_loads, p.time_steps] >= 0) + @variable(m, dvHeatFromStorageToTurbine[1:p.n_scenarios, p.s.storage.types.hot, p.heating_loads, p.time_steps] >= 0) end end end if !isempty(p.techs.cooling) - @variable(m, dvCoolingProduction[p.techs.cooling, p.time_steps] >= 0) + @variable(m, dvCoolingProduction[1:p.n_scenarios, p.techs.cooling, p.time_steps] >= 0) end if !isempty(p.techs.steam_turbine) if !isempty(p.techs.can_supply_steam_turbine) - @variable(m, dvThermalToSteamTurbine[p.techs.can_supply_steam_turbine, p.heating_loads, p.time_steps] >= 0) + @variable(m, dvThermalToSteamTurbine[1:p.n_scenarios, p.techs.can_supply_steam_turbine, p.heating_loads, p.time_steps] >= 0) elseif !any(p.s.storage.attr[b].can_supply_steam_turbine for b in p.s.storage.types.hot) throw(@error("Steam turbine is present, but set p.techs.can_supply_steam_turbine is empty and no storage is compatible with steam turbine.")) end @@ -756,9 +759,10 @@ function add_variables!(m::JuMP.AbstractModel, p::REoptInputs) if p.s.settings.off_grid_flag @variables m begin - dvOpResFromBatt[p.s.storage.types.elec, p.time_steps_without_grid] >= 0 # Operating reserves provided by the electric storage [kW] - dvOpResFromTechs[p.techs.providing_oper_res, p.time_steps_without_grid] >= 0 # Operating reserves provided by techs [kW] + dvOpResFromBatt[1:p.n_scenarios, p.s.storage.types.elec, p.time_steps_without_grid] >= 0 # Operating reserves provided by the electric storage [kW] + dvOpResFromTechs[1:p.n_scenarios, p.techs.providing_oper_res, p.time_steps_without_grid] >= 0 # Operating reserves provided by techs [kW] 1 >= dvOffgridLoadServedFraction[p.time_steps_without_grid] >= 0 # Critical load served in each time_step. Applied in off-grid scenarios only. [fraction] end end end + diff --git a/src/core/reopt_inputs.jl b/src/core/reopt_inputs.jl index bbbd6d0f6..f72b9fdaa 100644 --- a/src/core/reopt_inputs.jl +++ b/src/core/reopt_inputs.jl @@ -138,6 +138,11 @@ struct REoptInputs{ScenarioType <: AbstractScenario} <: AbstractInputs unavailability::Dict{String, Array{Float64,1}} # (techs.elec) absorption_chillers_using_heating_load::Dict{String,Array{String,1}} # ("AbsorptionChiller" or empty) avoided_capex_by_ashp_present_value::Dict{String, <:Real} # HVAC upgrade costs avoided (ASHP) + # Uncertainty fields + n_scenarios::Int # Number of uncertainty scenarios + scenario_probabilities::Vector{Float64} # Probability of each scenario + loads_kw_by_scenario::Dict{Int, Array{Float64,1}} # scenario_id => loads + production_factor_by_scenario::Dict{Int, Dict{String, Array{Float64,1}}} # scenario => tech => factors end @@ -259,6 +264,61 @@ function REoptInputs(s::AbstractScenario) end unavailability = get_unavailability_by_tech(s, techs, time_steps) + # Generate uncertainty scenarios + load_scenarios, load_probs = generate_load_scenarios( + s.electric_load.loads_kw, + s.load_uncertainty + ) + + # Extract production factors into Dict format for scenario generation + nominal_prod_factor = Dict{String, Vector{Float64}}() + for tech in techs.all + nominal_prod_factor[tech] = Float64.([production_factor[tech, ts] for ts in time_steps]) + end + + # Identify renewable techs for production uncertainty + renewable_tech_names = String[] + for pv in s.pvs + push!(renewable_tech_names, pv.name) + end + if s.wind.max_kw > 0 + push!(renewable_tech_names, "Wind") + end + + prod_scenarios, prod_probs = generate_production_scenarios( + nominal_prod_factor, + s.production_uncertainty, + renewable_tech_names + ) + + # Combine scenarios if both are enabled, or use individual scenarios + if s.load_uncertainty.enabled && s.production_uncertainty.enabled + loads_kw_by_scenario, production_factor_by_scenario, scenario_probabilities = + combine_load_production_scenarios(load_scenarios, load_probs, prod_scenarios, prod_probs) + elseif s.load_uncertainty.enabled + loads_kw_by_scenario = load_scenarios + scenario_probabilities = load_probs + production_factor_by_scenario = Dict{Int, Dict{String, Vector{Float64}}}( + s => Dict{String, Vector{Float64}}(k => Float64.(copy(v)) for (k, v) in nominal_prod_factor) + for s in 1:length(load_scenarios) + ) + elseif s.production_uncertainty.enabled + loads_kw_by_scenario = Dict{Int, Vector{Float64}}( + idx => Float64.(s.electric_load.loads_kw) for idx in 1:length(prod_scenarios) + ) + scenario_probabilities = prod_probs + production_factor_by_scenario = prod_scenarios + else + # No uncertainty - single deterministic scenario + loads_kw_by_scenario = Dict{Int, Vector{Float64}}(1 => Float64.(s.electric_load.loads_kw)) + production_factor_by_scenario = Dict{Int, Dict{String, Vector{Float64}}}( + 1 => Dict{String, Vector{Float64}}(k => Float64.(copy(v)) for (k, v) in nominal_prod_factor) + ) + scenario_probabilities = [1.0] + end + + n_scenarios = length(scenario_probabilities) + REoptInputs( s, techs, @@ -327,7 +387,11 @@ function REoptInputs(s::AbstractScenario) heating_loads_served_by_tes, unavailability, absorption_chillers_using_heating_load, - avoided_capex_by_ashp_present_value + avoided_capex_by_ashp_present_value, + n_scenarios, + scenario_probabilities, + loads_kw_by_scenario, + production_factor_by_scenario ) end @@ -1396,4 +1460,4 @@ function get_unavailability_by_tech(s::AbstractScenario, techs::Techs, time_step unavailability = Dict(""=>Float64[]) end return unavailability -end \ No newline at end of file +end diff --git a/src/core/reopt_multinode.jl b/src/core/reopt_multinode.jl index 9ffb82a96..b53cb0bf3 100644 --- a/src/core/reopt_multinode.jl +++ b/src/core/reopt_multinode.jl @@ -28,7 +28,7 @@ function add_variables!(m::JuMP.AbstractModel, ps::AbstractVector{REoptInputs{T} for dv in dvs_idx_on_techs_time_steps x = dv*_n - m[Symbol(x)] = @variable(m, [p.techs.all, p.time_steps], base_name=x, lower_bound=0) + m[Symbol(x)] = @variable(m, [1:p.n_scenarios, p.techs.all, p.time_steps], base_name=x, lower_bound=0) end for dv in dvs_idx_on_storagetypes @@ -38,33 +38,33 @@ function add_variables!(m::JuMP.AbstractModel, ps::AbstractVector{REoptInputs{T} for dv in dvs_idx_on_storagetypes_time_steps x = dv*_n - m[Symbol(x)] = @variable(m, [p.s.storage.types.all, p.time_steps], base_name=x, lower_bound=0) + m[Symbol(x)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, p.time_steps], base_name=x, lower_bound=0) end dv = "dvGridToStorage"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.elec, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.elec, p.time_steps], base_name=dv, lower_bound=0) dv = "dvGridPurchase"*_n - m[Symbol(dv)] = @variable(m, [p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.time_steps, 1:p.s.electric_tariff.n_energy_tiers], base_name=dv, lower_bound=0) dv = "dvPeakDemandTOU"*_n - m[Symbol(dv)] = @variable(m, [p.ratchets, 1], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.ratchets, 1:p.s.electric_tariff.n_tou_demand_tiers], base_name=dv, lower_bound=0) dv = "dvPeakDemandMonth"*_n - m[Symbol(dv)] = @variable(m, [p.months, 1], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.months, 1:p.s.electric_tariff.n_monthly_demand_tiers], base_name=dv, lower_bound=0) dv = "dvProductionToStorage"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.all, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) dv = "dvStoredEnergy"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.all, 0:p.time_steps[end]], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, 0:p.time_steps[end]], base_name=dv, lower_bound=0) dv = "MinChargeAdder"*_n m[Symbol(dv)] = @variable(m, base_name=dv, lower_bound=0) if !isempty(p.s.electric_tariff.export_bins) dv = "dvProductionToGrid"*_n - m[Symbol(dv)] = @variable(m, [p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps], base_name=dv, lower_bound=0) end ex_name = "TotalTechCapCosts"*_n @@ -158,13 +158,13 @@ function build_reopt!(m::JuMP.AbstractModel, ps::AbstractVector{REoptInputs{T}}) for b in p.s.storage.types.all if p.s.storage.attr[b].max_kw == 0 || p.s.storage.attr[b].max_kwh == 0 - @constraint(m, [ts in p.time_steps], m[Symbol("dvStoredEnergy"*_n)][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStoredEnergy"*_n)][s, b, ts] == 0) @constraint(m, m[Symbol("dvStorageEnergy"*_n)][b] == 0) @constraint(m, m[Symbol("dvStoragePower"*_n)][b] == 0) - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - m[Symbol("dvProductionToStorage"*_n)][b, t, ts] == 0) - @constraint(m, [ts in p.time_steps], m[Symbol("dvDischargeFromStorage"*_n)][b, ts] == 0) - @constraint(m, [ts in p.time_steps], m[Symbol("dvGridToStorage"*_n)][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvGridToStorage"*_n)][s, b, ts] == 0) if b in p.s.storage.types.elec if (p.s.storage.attr[b].installed_cost_constant != 0) || (p.s.storage.attr[b].replace_cost_constant != 0) @constraint(m, m[Symbol("binIncludeStorageCostConstant"*_n)][b] == 0) @@ -238,8 +238,8 @@ function add_objective!(m::JuMP.AbstractModel, ps::AbstractVector{REoptInputs{T} @objective(m, Min, sum(m[Symbol(string("Costs_", p.s.site.node))] for p in ps)) else # Keep SOC high @objective(m, Min, sum(m[Symbol(string("Costs_", p.s.site.node))] for p in ps) - - sum(sum(sum(m[Symbol(string("dvStoredEnergy_", p.s.site.node))][b, ts] - for ts in p.time_steps) for b in p.s.storage.types.elec) for p in ps) / (8760. / ps[1].hours_per_time_step)) + - sum(sum(sum(sum(p.scenario_probabilities[s] * m[Symbol(string("dvStoredEnergy_", p.s.site.node))][s, b, ts] + for s in 1:p.n_scenarios) for ts in p.time_steps) for b in p.s.storage.types.elec) for p in ps) / (8760. / ps[1].hours_per_time_step)) end # TODO need to handle different hours_per_time_step? nothing end diff --git a/src/core/scenario.jl b/src/core/scenario.jl index 30ea91200..76dcbdbaa 100644 --- a/src/core/scenario.jl +++ b/src/core/scenario.jl @@ -28,6 +28,8 @@ struct Scenario <: AbstractScenario cst::Union{CST, Nothing} ashp::Union{ASHP, Nothing} ashp_wh::Union{ASHP, Nothing} + load_uncertainty::TimeSeriesUncertainty + production_uncertainty::TimeSeriesUncertainty end """ @@ -1005,6 +1007,49 @@ function Scenario(d::Dict; flex_hvac_from_json=false) end end + # Parse uncertainty specifications + if haskey(d, "ElectricLoad") && haskey(d["ElectricLoad"], "uncertainty") + unc_dict = dictkeys_tosymbols(d["ElectricLoad"]["uncertainty"]) + # Convert Any[] arrays from JSON to properly typed arrays + if haskey(unc_dict, :deviation_fractions) + unc_dict[:deviation_fractions] = Float64.(unc_dict[:deviation_fractions]) + end + if haskey(unc_dict, :deviation_probabilities) + unc_dict[:deviation_probabilities] = Float64.(unc_dict[:deviation_probabilities]) + end + load_uncertainty = TimeSeriesUncertainty(; unc_dict...) + else + load_uncertainty = TimeSeriesUncertainty() # Default: disabled + end + + # Check for production uncertainty in PV or Wind + production_uncertainty = TimeSeriesUncertainty() # Default: disabled + if haskey(d, "PV") + pv_dict = typeof(d["PV"]) <: AbstractArray ? d["PV"][1] : d["PV"] + if haskey(pv_dict, "production_uncertainty") + unc_dict = dictkeys_tosymbols(pv_dict["production_uncertainty"]) + # Convert Any[] arrays from JSON to properly typed arrays + if haskey(unc_dict, :deviation_fractions) + unc_dict[:deviation_fractions] = Float64.(unc_dict[:deviation_fractions]) + end + if haskey(unc_dict, :deviation_probabilities) + unc_dict[:deviation_probabilities] = Float64.(unc_dict[:deviation_probabilities]) + end + production_uncertainty = TimeSeriesUncertainty(; unc_dict...) + end + end + if haskey(d, "Wind") && haskey(d["Wind"], "production_uncertainty") + unc_dict = dictkeys_tosymbols(d["Wind"]["production_uncertainty"]) + # Convert Any[] arrays from JSON to properly typed arrays + if haskey(unc_dict, :deviation_fractions) + unc_dict[:deviation_fractions] = Float64.(unc_dict[:deviation_fractions]) + end + if haskey(unc_dict, :deviation_probabilities) + unc_dict[:deviation_probabilities] = Float64.(unc_dict[:deviation_probabilities]) + end + production_uncertainty = TimeSeriesUncertainty(; unc_dict...) + end + return Scenario( settings, site, @@ -1033,7 +1078,9 @@ function Scenario(d::Dict; flex_hvac_from_json=false) electric_heater, cst, ashp, - ashp_wh + ashp_wh, + load_uncertainty, + production_uncertainty ) end diff --git a/src/core/uncertainty.jl b/src/core/uncertainty.jl new file mode 100644 index 000000000..171c02592 --- /dev/null +++ b/src/core/uncertainty.jl @@ -0,0 +1,481 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. + +""" + TimeSeriesUncertainty + +Defines uncertainty parameters for time series data (loads, production factors, etc.). + +# Fields +- `enabled::Bool`: Whether uncertainty is enabled +- `method::String`: Uncertainty method - "time_invariant", "discrete", "normal", or "uniform" +- `deviation_fractions::Array{<:Real,1}`: Array of fractional deviations from nominal (for time_invariant/discrete) +- `deviation_probabilities::Array{<:Real,1}`: Probability of each deviation (for time_invariant) or sampling distribution (for discrete), must sum to 1.0 +- `n_samples::Int`: Number of samples to generate (used for discrete, normal, uniform) +- `mean::Union{Float64, Nothing}`: Mean of Normal distribution (for method="normal") +- `std::Union{Float64, Nothing}`: Standard deviation of Normal distribution (for method="normal") +- `lower_bound::Union{Float64, Nothing}`: Lower bound for Uniform distribution (for method="uniform") +- `upper_bound::Union{Float64, Nothing}`: Upper bound for Uniform distribution (for method="uniform") + +# Example (Time-Invariant - fixed scenarios with same deviation across all timesteps) +```julia +TimeSeriesUncertainty( + enabled=true, + method="time_invariant", + deviation_fractions=[-0.10, 0.0, 0.10], + deviation_probabilities=[0.25, 0.50, 0.25] +) +``` + +# Example (Discrete - Monte Carlo sampling from discrete distribution at each timestep) +```julia +TimeSeriesUncertainty( + enabled=true, + method="discrete", + deviation_fractions=[-0.10, 0.0, 0.10], + deviation_probabilities=[0.25, 0.50, 0.25], + n_samples=3 +) +``` + +# Example (Normal - Monte Carlo sampling from Normal distribution at each timestep) +```julia +TimeSeriesUncertainty( + enabled=true, + method="normal", + mean=0.0, + std=0.15, + n_samples=3 +) +``` + +# Example (Uniform - Monte Carlo sampling from Uniform distribution at each timestep) +```julia +TimeSeriesUncertainty( + enabled=true, + method="uniform", + lower_bound=-0.3, + upper_bound=0.3, + n_samples=3 +) +``` +""" +struct TimeSeriesUncertainty + enabled::Bool + method::String + deviation_fractions::Array{<:Real,1} + deviation_probabilities::Array{<:Real,1} + n_samples::Int + mean::Union{Float64, Nothing} + std::Union{Float64, Nothing} + lower_bound::Union{Float64, Nothing} + upper_bound::Union{Float64, Nothing} + + function TimeSeriesUncertainty(; + enabled::Bool = false, + method::String = "time_invariant", + deviation_fractions::Array{<:Real,1} = [-0.1, 0.0, 0.1], + deviation_probabilities::Array{<:Real,1} = [0.25, 0.50, 0.25], + n_samples::Int = 3, + mean::Union{Float64, Nothing} = nothing, + std::Union{Float64, Nothing} = nothing, + lower_bound::Union{Float64, Nothing} = nothing, + upper_bound::Union{Float64, Nothing} = nothing + ) + # Validate method + if !(method in ["time_invariant", "discrete", "normal", "uniform"]) + throw(@error("TimeSeriesUncertainty method must be 'time_invariant', 'discrete', 'normal', or 'uniform'")) + end + + # Validate time_invariant/discrete parameters + if method in ["time_invariant", "discrete"] + if abs(sum(deviation_probabilities) - 1.0) > 1e-6 + throw(@error("TimeSeriesUncertainty deviation_probabilities must sum to 1.0")) + end + if length(deviation_fractions) != length(deviation_probabilities) + throw(@error("TimeSeriesUncertainty deviation_fractions and deviation_probabilities must have the same length")) + end + if any(abs.(deviation_fractions) .> 1.0) + throw(@error("TimeSeriesUncertainty deviation_fractions must be between -1 and 1")) + end + end + + # Validate that deviation_probabilities is not used for continuous distributions + if method in ["normal", "uniform"] && deviation_probabilities != [0.25, 0.50, 0.25] + throw(@error("TimeSeriesUncertainty deviation_probabilities should not be specified for method='$method'")) + end + + # Validate normal distribution parameters + if method == "normal" + if isnothing(mean) || isnothing(std) + throw(@error("TimeSeriesUncertainty method='normal' requires mean and std parameters")) + end + if std <= 0 + throw(@error("TimeSeriesUncertainty std must be positive for normal distribution")) + end + end + + # Validate uniform distribution parameters + if method == "uniform" + if isnothing(lower_bound) || isnothing(upper_bound) + throw(@error("TimeSeriesUncertainty method='uniform' requires lower_bound and upper_bound parameters")) + end + if lower_bound >= upper_bound + throw(@error("TimeSeriesUncertainty lower_bound must be less than upper_bound")) + end + if abs(lower_bound) > 1.0 || abs(upper_bound) > 1.0 + throw(@error("TimeSeriesUncertainty bounds must be between -1 and 1")) + end + end + + # Validate n_samples for sampling methods + if method in ["discrete", "normal", "uniform"] && n_samples < 1 + throw(@error("TimeSeriesUncertainty n_samples must be at least 1 for sampling methods")) + end + + new(enabled, method, deviation_fractions, deviation_probabilities, n_samples, mean, std, lower_bound, upper_bound) + end +end + + +""" + sample_deviation_from_distribution(deviation_fractions, deviation_probabilities) + +Sample a single deviation value from the discrete probability distribution. + +# Arguments +- `deviation_fractions::Array{<:Real, 1}`: Deviation values +- `deviation_probabilities::Array{<:Real, 1}`: Probabilities for each deviation + +# Returns +- `deviation::Float64`: Sampled deviation value +""" +function sample_deviation_from_distribution( + deviation_fractions::Array{<:Real, 1}, + deviation_probabilities::Array{<:Real, 1} +) + # Create cumulative distribution + cum_probs = cumsum(deviation_probabilities) + + # Sample uniform random number + r = rand() + + # Find which bin it falls into + for (idx, cum_prob) in enumerate(cum_probs) + if r <= cum_prob + return Float64(deviation_fractions[idx]) + end + end + + # Fallback (should not reach here if probabilities sum to 1.0) + return Float64(deviation_fractions[end]) +end + + +""" + sample_normal_deviation(mean, std) + +Sample a deviation value from a Normal (Gaussian) distribution. + +# Arguments +- `mean::Float64`: Mean of the distribution +- `std::Float64`: Standard deviation of the distribution + +# Returns +- `deviation::Float64`: Sampled deviation value +""" +function sample_normal_deviation(mean::Float64, std::Float64) + return randn() * std + mean +end + + +""" + sample_uniform_deviation(lower_bound, upper_bound) + +Sample a deviation value from a Uniform distribution. + +# Arguments +- `lower_bound::Float64`: Lower bound of the distribution +- `upper_bound::Float64`: Upper bound of the distribution + +# Returns +- `deviation::Float64`: Sampled deviation value +""" +function sample_uniform_deviation(lower_bound::Float64, upper_bound::Float64) + return rand() * (upper_bound - lower_bound) + lower_bound +end + + +""" + apply_sampled_deviations(nominal_values, n_samples, sampling_func) + +Core sampling logic: apply random deviations to nominal values. + +# Arguments +- `nominal_values::Array{<:Real, 1}`: Nominal values (loads or production factors) +- `n_samples::Int`: Number of scenario samples to generate +- `sampling_func::Function`: Function that returns a deviation when called + +# Returns +- `scenarios::Dict{Int, Vector{Float64}}`: Sampled scenarios with deviations applied +""" +function apply_sampled_deviations( + nominal_values::Array{<:Real, 1}, + n_samples::Int, + sampling_func::Function +) + n_timesteps = length(nominal_values) + scenarios = Dict{Int, Vector{Float64}}() + + for sample_idx in 1:n_samples + sampled_values = zeros(Float64, n_timesteps) + for ts in 1:n_timesteps + deviation = sampling_func() + sampled_values[ts] = nominal_values[ts] * (1.0 + deviation) + end + scenarios[sample_idx] = sampled_values + end + + return scenarios +end + + +""" + generate_production_scenarios_generic(nominal_production_factor, n_samples, renewable_techs, sampling_func) + +Generic production scenario generator - applies sampling to renewable techs only. + +# Arguments +- `nominal_production_factor::Dict{String, Array{Float64, 1}}`: Nominal production factors by tech +- `n_samples::Int`: Number of samples to generate +- `renewable_techs::Array{String, 1}`: Technologies to apply uncertainty to +- `sampling_func::Function`: Function that returns a deviation when called + +# Returns +- `scenarios::Dict{Int, Dict{String, Array{Float64, 1}}}`: Generated scenarios +- `probabilities::Array{Float64, 1}`: Equal probabilities for all scenarios +""" +function generate_production_scenarios_generic( + nominal_production_factor::Dict{String, Array{Float64, 1}}, + n_samples::Int, + renewable_techs::Array{String, 1}, + sampling_func::Function +) + scenarios = Dict{Int, Dict{String, Vector{Float64}}}() + + for sample_idx in 1:n_samples + scenarios[sample_idx] = Dict{String, Vector{Float64}}() + + for (tech, factors) in nominal_production_factor + if tech in renewable_techs + # Apply sampled deviations to this tech + scenarios[sample_idx][tech] = apply_sampled_deviations( + factors, 1, sampling_func + )[1] # Get first (and only) sample + else + # No uncertainty for this tech + scenarios[sample_idx][tech] = Float64.(copy(factors)) + end + end + end + + probabilities = fill(1.0 / n_samples, n_samples) + return scenarios, probabilities +end + + +""" + get_sampling_function(uncertainty::TimeSeriesUncertainty) + +Create appropriate sampling function based on uncertainty method. + +# Returns +- `Function`: A zero-argument function that returns a sampled deviation +""" +function get_sampling_function(uncertainty::TimeSeriesUncertainty) + if uncertainty.method == "discrete" + return () -> sample_deviation_from_distribution( + uncertainty.deviation_fractions, + uncertainty.deviation_probabilities + ) + elseif uncertainty.method == "normal" + return () -> sample_normal_deviation(uncertainty.mean, uncertainty.std) + elseif uncertainty.method == "uniform" + return () -> sample_uniform_deviation(uncertainty.lower_bound, uncertainty.upper_bound) + else + error("Invalid method for sampling: $(uncertainty.method)") + end +end + + +""" + generate_load_scenarios(nominal_loads_kw, uncertainty) + +Generate load scenarios based on uncertainty specification. +""" +function generate_load_scenarios( + nominal_loads_kw::Array{<:Real, 1}, + uncertainty::TimeSeriesUncertainty +) + if !uncertainty.enabled + return Dict{Int, Vector{Float64}}(1 => Float64.(nominal_loads_kw)), [1.0] + end + + if uncertainty.method == "time_invariant" + return generate_load_scenarios_time_invariant(nominal_loads_kw, uncertainty) + else + # All sampling methods (discrete, normal, uniform) use same logic + sampling_func = get_sampling_function(uncertainty) + scenarios = apply_sampled_deviations(nominal_loads_kw, uncertainty.n_samples, sampling_func) + probabilities = fill(1.0 / uncertainty.n_samples, uncertainty.n_samples) + return scenarios, probabilities + end +end + + +""" + generate_load_scenarios_time_invariant(nominal_loads_kw, uncertainty) + +Generate time-invariant load scenarios. +All timesteps in a scenario have the same deviation applied. + +# Arguments +- `nominal_loads_kw::Array{<:Real, 1}`: Nominal load profile +- `uncertainty::TimeSeriesUncertainty`: Uncertainty specification + +# Returns +- `scenarios::Dict{Int, Array{Float64, 1}}`: Dictionary mapping scenario ID to load profile +- `probabilities::Array{Float64, 1}`: Probability of each scenario +""" +function generate_load_scenarios_time_invariant( + nominal_loads_kw::Array{<:Real, 1}, + uncertainty::TimeSeriesUncertainty +) + scenarios = Dict{Int, Vector{Float64}}() + probabilities = Float64[] + + # Generate scenario for each deviation + for (idx, (deviation, prob)) in enumerate(zip(uncertainty.deviation_fractions, uncertainty.deviation_probabilities)) + scenarios[idx] = Float64.(nominal_loads_kw .* (1.0 + deviation)) + push!(probabilities, prob) + end + + return scenarios, probabilities +end + + +""" + generate_production_scenarios(nominal_production_factor, uncertainty, renewable_techs) + +Generate production factor scenarios for renewable technologies. +""" +function generate_production_scenarios( + nominal_production_factor::Dict{String, Array{Float64, 1}}, + uncertainty::TimeSeriesUncertainty, + renewable_techs::Array{String, 1} +) + if !uncertainty.enabled + return Dict{Int, Dict{String, Vector{Float64}}}(1 => nominal_production_factor), [1.0] + end + + if uncertainty.method == "time_invariant" + return generate_production_scenarios_time_invariant(nominal_production_factor, uncertainty, renewable_techs) + else + # All sampling methods (discrete, normal, uniform) use same logic + sampling_func = get_sampling_function(uncertainty) + return generate_production_scenarios_generic( + nominal_production_factor, uncertainty.n_samples, renewable_techs, sampling_func + ) + end +end + + +""" + generate_production_scenarios_time_invariant(nominal_production_factor, uncertainty, renewable_techs) + +Generate time-invariant production scenarios. +All timesteps in a scenario have the same deviation applied. + +# Arguments +- `nominal_production_factor::Dict{String, Array{Float64, 1}}`: Nominal production factors by tech +- `uncertainty::TimeSeriesUncertainty`: Uncertainty specification +- `renewable_techs::Array{String, 1}`: Technologies to apply uncertainty to + +# Returns +- `scenarios::Dict{Int, Dict{String, Array{Float64, 1}}}`: Dictionary mapping scenario ID to production factors by tech +- `probabilities::Array{Float64, 1}`: Probability of each scenario +""" +function generate_production_scenarios_time_invariant( + nominal_production_factor::Dict{String, Array{Float64, 1}}, + uncertainty::TimeSeriesUncertainty, + renewable_techs::Array{String, 1} +) + scenarios = Dict{Int, Dict{String, Vector{Float64}}}() + probabilities = Float64[] + + # Generate scenario for each deviation + for (idx, (deviation, prob)) in enumerate(zip(uncertainty.deviation_fractions, uncertainty.deviation_probabilities)) + scenarios[idx] = Dict{String, Vector{Float64}}() + + # Copy all tech production factors + for (tech, factors) in nominal_production_factor + if tech in renewable_techs + # Apply uncertainty + scenarios[idx][tech] = Float64.(factors .* (1.0 + deviation)) + else + # No uncertainty for this tech + scenarios[idx][tech] = Float64.(copy(factors)) + end + end + + push!(probabilities, prob) + end + + return scenarios, probabilities +end + + +""" + combine_load_production_scenarios(load_scenarios, load_probs, prod_scenarios, prod_probs) + +Combine independent load and production scenarios into joint scenarios. +Assumes independence between load and production uncertainty. + +# Arguments +- `load_scenarios::Dict{Int, Array{Float64, 1}}`: Load scenarios +- `load_probs::Array{Float64, 1}`: Load scenario probabilities +- `prod_scenarios::Dict{Int, Dict{String, Array{Float64, 1}}}`: Production scenarios +- `prod_probs::Array{Float64, 1}`: Production scenario probabilities + +# Returns +- `combined_loads::Dict{Int, Array{Float64, 1}}`: Combined load scenarios +- `combined_prods::Dict{Int, Dict{String, Array{Float64, 1}}}`: Combined production scenarios +- `combined_probs::Array{Float64, 1}`: Combined scenario probabilities + +# Note +If both load and production uncertainty are enabled, this creates 9 scenarios (3 load × 3 production). +""" +function combine_load_production_scenarios( + load_scenarios::Dict{Int, Array{Float64, 1}}, + load_probs::Array{Float64, 1}, + prod_scenarios::Dict{Int, Dict{String, Array{Float64, 1}}}, + prod_probs::Array{Float64, 1} +) + n_load = length(load_scenarios) + n_prod = length(prod_scenarios) + + combined_loads = Dict{Int, Array{Float64, 1}}() + combined_prods = Dict{Int, Dict{String, Array{Float64, 1}}}() + combined_probs = Float64[] + + scenario_id = 1 + for i in 1:n_load + for j in 1:n_prod + combined_loads[scenario_id] = load_scenarios[i] + combined_prods[scenario_id] = prod_scenarios[j] + push!(combined_probs, load_probs[i] * prod_probs[j]) + scenario_id += 1 + end + end + + return combined_loads, combined_prods, combined_probs +end diff --git a/src/core/utils.jl b/src/core/utils.jl index 5c8267b41..0f643e720 100644 --- a/src/core/utils.jl +++ b/src/core/utils.jl @@ -219,6 +219,17 @@ function dictkeys_tosymbols(d::Dict) end end end + # Convert numeric boolean values (0.0/1.0) to proper Bool type + if k in [ + "off_grid_flag", "add_soc_incentive", "include_climate_in_objective", + "include_health_in_objective", "include_export_cost_series_in_results" + ] && !isnothing(v) && !(typeof(v) <: Bool) + try + v = Bool(v) + catch + throw(@error("Unable to convert $k to Bool. Expected boolean or 0/1, got: $v")) + end + end if k in [ "fuel_cost_per_mmbtu", "wholesale_rate", "export_rate_beyond_net_metering_limit", # for ERP diff --git a/src/core/wind.jl b/src/core/wind.jl index e229642b5..0b1398473 100644 --- a/src/core/wind.jl +++ b/src/core/wind.jl @@ -34,6 +34,7 @@ can_wholesale = true, can_export_beyond_nem_limit = true operating_reserve_required_fraction::Real = off_grid_flag ? 0.50 : 0.0, # Only applicable when `off_grid_flag` is true. Applied to each time_step as a % of wind generation serving load. + production_uncertainty::Union{Dict, Nothing} = nothing # OUU parameter - handled at Scenario level, not stored in Wind ``` !!! note "Default assumptions" `size_class` must be one of ["residential", "commercial", "medium", "large"]. If `size_class` is not provided then it is determined based on the average electric load. @@ -135,6 +136,7 @@ struct Wind <: AbstractTech can_curtail= true, average_elec_load = 0.0, operating_reserve_required_fraction::Real = off_grid_flag ? 0.50 : 0.0, # Only applicable when `off_grid_flag` is true. Applied to each time_step as a % of wind generation serving load. + production_uncertainty::Union{Dict, Nothing} = nothing # OUU parameter - handled at Scenario level, not stored in Wind ) size_class_to_hub_height = Dict( "residential"=> 20, diff --git a/src/mpc/constraints.jl b/src/mpc/constraints.jl index 39b2e5033..d5e1ae11c 100644 --- a/src/mpc/constraints.jl +++ b/src/mpc/constraints.jl @@ -1,24 +1,24 @@ # REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. function add_previous_monthly_peak_constraint(m::JuMP.AbstractModel, p::MPCInputs; _n="") - ## Constraint (11d): Monthly peak demand is >= demand at each time step in the month - @constraint(m, [mth in p.months, ts in p.s.electric_tariff.time_steps_monthly[mth]], - m[Symbol("dvPeakDemandMonth"*_n)][mth, 1] >= p.s.electric_tariff.monthly_previous_peak_demands[mth] + ## Constraint (11d): Monthly peak demand is >= previous peak demand for each month + @constraint(m, [s in 1:p.n_scenarios, mth in p.months, tier in 1:p.s.electric_tariff.n_monthly_demand_tiers], + m[Symbol("dvPeakDemandMonth"*_n)][s, mth, tier] >= p.s.electric_tariff.monthly_previous_peak_demands[mth] ) end function add_previous_tou_peak_constraint(m::JuMP.AbstractModel, p::MPCInputs; _n="") - ## Constraint (12d): TOU peak demand is >= demand at each time step in the period` - @constraint(m, [r in p.ratchets], - m[Symbol("dvPeakDemandTOU"*_n)][r, 1] >= p.s.electric_tariff.tou_previous_peak_demands[r] + ## Constraint (12d): TOU peak demand is >= previous peak demand for each ratchet + @constraint(m, [s in 1:p.n_scenarios, r in p.ratchets, tier in 1:p.s.electric_tariff.n_tou_demand_tiers], + m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier] >= p.s.electric_tariff.tou_previous_peak_demands[r] ) end function add_grid_draw_limits(m::JuMP.AbstractModel, p::MPCInputs; _n="") - @constraint(m, [ts in p.time_steps], + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], sum( - m[Symbol("dvGridPurchase"*_n)][ts, tier] + m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers ) <= p.s.limits.grid_draw_limit_kw_by_time_step[ts] ) @@ -26,9 +26,9 @@ end function add_export_limits(m::JuMP.AbstractModel, p::MPCInputs; _n="") - @constraint(m, [ts in p.time_steps], + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], sum( - sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t]) + sum(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts] for u in p.export_bins_by_tech[t]) for t in p.techs.elec ) <= p.s.limits.export_limit_kw_by_time_step[ts] ) diff --git a/src/mpc/inputs.jl b/src/mpc/inputs.jl index 3aeece174..ecb09f732 100644 --- a/src/mpc/inputs.jl +++ b/src/mpc/inputs.jl @@ -25,6 +25,11 @@ struct MPCInputs <: AbstractInputs ghp_options::UnitRange{Int64} # Range of the number of GHP options fuel_cost_per_kwh::Dict{String, AbstractArray} # Fuel cost array for all time_steps heating_loads::Vector{String} # list of heating loads + # Uncertainty fields (MPC is deterministic, but fields needed for compatibility) + n_scenarios::Int + scenario_probabilities::Vector{Float64} + loads_kw_by_scenario::Dict{Int, Vector{Float64}} + production_factor_by_scenario::Dict{Int, Dict{String, Vector{Float64}}} end @@ -71,6 +76,17 @@ function MPCInputs(s::MPCScenario) ghp_options = 1:0 heating_loads = Vector{String}() + # MPC is deterministic - single scenario with probability 1.0 + # TODO could consider OUU for dispatch decisions in MPC, even with sizes being specified/fixed + n_scenarios = 1 + scenario_probabilities = [1.0] + loads_kw_by_scenario = Dict{Int, Vector{Float64}}(1 => Float64.(s.electric_load.loads_kw)) + production_factor_by_scenario = Dict{Int, Dict{String, Vector{Float64}}}( + 1 => Dict{String, Vector{Float64}}( + t => Float64.([production_factor[t, ts] for ts in time_steps]) for t in techs.all + ) + ) + MPCInputs( s, techs, @@ -99,7 +115,11 @@ function MPCInputs(s::MPCScenario) # s.site.mg_tech_sizes_equal_grid_sizes, # s.site.node, fuel_cost_per_kwh, - heating_loads + heating_loads, + n_scenarios, + scenario_probabilities, + loads_kw_by_scenario, + production_factor_by_scenario ) end diff --git a/src/mpc/model.jl b/src/mpc/model.jl index d40a80da3..2faef90ae 100644 --- a/src/mpc/model.jl +++ b/src/mpc/model.jl @@ -37,7 +37,7 @@ function run_mpc(m::JuMP.AbstractModel, p::MPCInputs) if !p.s.settings.add_soc_incentive || !("ElectricStorage" in p.s.storage.types.elec) @objective(m, Min, m[:Costs]) else # Keep SOC high - @objective(m, Min, m[:Costs] - sum(m[:dvStoredEnergy]["ElectricStorage", ts] for ts in p.time_steps) / + @objective(m, Min, m[:Costs] - sum(m[:dvStoredEnergy][s, "ElectricStorage", ts] for s in 1:p.n_scenarios, ts in p.time_steps) / (8760. / p.hours_per_time_step) ) end @@ -80,25 +80,27 @@ function build_mpc!(m::JuMP.AbstractModel, p::MPCInputs) for ts in p.time_steps_without_grid - fix(m[:dvGridPurchase][ts], 0.0, force=true) + for s in 1:p.n_scenarios, tier in 1:p.s.electric_tariff.n_energy_tiers + fix(m[:dvGridPurchase][s, ts, tier], 0.0, force=true) + end - for t in p.s.storage.types.elec - fix(m[:dvGridToStorage][t, ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.s.storage.types.elec + fix(m[:dvGridToStorage][s, t, ts], 0.0, force=true) end - for t in p.techs.elec, u in p.export_bins_by_tech[t] - fix(m[:dvProductionToGrid][t, u, ts], 0.0, force=true) + for s in 1:p.n_scenarios, t in p.techs.elec, u in p.export_bins_by_tech[t] + fix(m[:dvProductionToGrid][s, t, u, ts], 0.0, force=true) end end for b in p.s.storage.types.all if p.s.storage.attr[b].size_kw == 0 || p.s.storage.attr[b].size_kwh == 0 - @constraint(m, [ts in p.time_steps], m[:dvStoredEnergy][b, ts] == 0) - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - m[:dvProductionToStorage][b, t, ts] == 0) - @constraint(m, [ts in p.time_steps], m[:dvDischargeFromStorage][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvStoredEnergy][s, b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + m[:dvProductionToStorage][s, b, t, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvDischargeFromStorage][s, b, ts] == 0) if b in p.s.storage.types.elec - @constraint(m, [ts in p.time_steps], m[:dvGridToStorage][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[:dvGridToStorage][s, b, ts] == 0) end else add_general_storage_dispatch_constraints(m, p, b) @@ -121,8 +123,8 @@ function build_mpc!(m::JuMP.AbstractModel, p::MPCInputs) add_production_constraints(m, p) if !isempty(p.techs.no_turndown) - @constraint(m, [t in p.techs.no_turndown, ts in p.time_steps], - m[:dvRatedProduction][t,ts] == m[:dvSize][t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.no_turndown, ts in p.time_steps], + m[:dvRatedProduction][s, t, ts] == m[:dvSize][t] ) end @@ -158,10 +160,10 @@ function build_mpc!(m::JuMP.AbstractModel, p::MPCInputs) add_gen_constraints(m, p) m[:TotalPerUnitProdOMCosts] += @expression(m, sum(p.s.generator.om_cost_per_kwh * p.hours_per_time_step * - m[:dvRatedProduction][t, ts] for t in p.techs.gen, ts in p.time_steps) + m[:dvRatedProduction][s, t, ts] for s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps) ) m[:TotalGenFuelCosts] = @expression(m, - sum(m[:dvFuelUsage][t,ts] * p.s.generator.fuel_cost_per_gallon for t in p.techs.gen, ts in p.time_steps) + sum(m[:dvFuelUsage][s, t, ts] * p.s.generator.fuel_cost_per_gallon for s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps) ) m[:TotalFuelCosts] += m[:TotalGenFuelCosts] end @@ -225,24 +227,24 @@ function add_variables!(m::JuMP.AbstractModel, p::MPCInputs) @variables m begin # dvSize[p.techs.all] >= 0 # System Size of Technology t [kW] # dvPurchaseSize[p.techs.all] >= 0 # system kW beyond existing_kw that must be purchased - dvGridPurchase[p.time_steps] >= 0 # Power from grid dispatched to meet electrical load [kW] - dvRatedProduction[p.techs.all, p.time_steps] >= 0 # Rated production of technology t [kW] - dvCurtail[p.techs.all, p.time_steps] >= 0 # [kW] - dvProductionToStorage[p.s.storage.types.all, p.techs.all, p.time_steps] >= 0 # Power from technology t used to charge storage system b [kW] - dvDischargeFromStorage[p.s.storage.types.all, p.time_steps] >= 0 # Power discharged from storage system b [kW] - dvGridToStorage[p.s.storage.types.elec, p.time_steps] >= 0 # Electrical power delivered to storage by the grid [kW] - dvStoredEnergy[p.s.storage.types.all, 0:p.time_steps[end]] >= 0 # State of charge of storage system b + dvGridPurchase[1:p.n_scenarios, p.time_steps, 1:p.s.electric_tariff.n_energy_tiers] >= 0 # Power from grid dispatched to meet electrical load [kW] + dvRatedProduction[1:p.n_scenarios, p.techs.all, p.time_steps] >= 0 # Rated production of technology t [kW] + dvCurtail[1:p.n_scenarios, p.techs.all, p.time_steps] >= 0 # [kW] + dvProductionToStorage[1:p.n_scenarios, p.s.storage.types.all, p.techs.all, p.time_steps] >= 0 # Power from technology t used to charge storage system b [kW] + dvDischargeFromStorage[1:p.n_scenarios, p.s.storage.types.all, p.time_steps] >= 0 # Power discharged from storage system b [kW] + dvGridToStorage[1:p.n_scenarios, p.s.storage.types.elec, p.time_steps] >= 0 # Electrical power delivered to storage by the grid [kW] + dvStoredEnergy[1:p.n_scenarios, p.s.storage.types.all, 0:p.time_steps[end]] >= 0 # State of charge of storage system b dvStoragePower[p.s.storage.types.all] >= 0 # Power capacity of storage system b [kW] dvStorageEnergy[p.s.storage.types.all] >= 0 # Energy capacity of storage system b [kWh] # TODO rm dvStoragePower/Energy dv's - dvPeakDemandTOU[p.ratchets, 1:1] >= 0 # Peak electrical power demand during ratchet r [kW] - dvPeakDemandMonth[p.months] >= 0 # Peak electrical power demand during month m [kW] + dvPeakDemandTOU[1:p.n_scenarios, p.ratchets, 1:p.s.electric_tariff.n_tou_demand_tiers] >= 0 # Peak electrical power demand during ratchet r [kW] + dvPeakDemandMonth[1:p.n_scenarios, p.months, 1:p.s.electric_tariff.n_monthly_demand_tiers] >= 0 # Peak electrical power demand during month m [kW] # MinChargeAdder >= 0 end # TODO: tiers in MPC tariffs and variables? if !isempty(p.s.electric_tariff.export_bins) - @variable(m, dvProductionToGrid[p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps] >= 0) + @variable(m, dvProductionToGrid[1:p.n_scenarios, p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps] >= 0) end m[:dvSize] = p.existing_sizes @@ -259,8 +261,8 @@ function add_variables!(m::JuMP.AbstractModel, p::MPCInputs) @warn """Adding binary variable to model gas generator. Some solvers are very slow with integer variables""" @variables m begin - dvFuelUsage[p.techs.gen, p.time_steps] >= 0 # Fuel burned by technology t in each time step [kWh] - binGenIsOnInTS[p.techs.gen, p.time_steps], Bin # 1 If technology t is operating in time step h; 0 otherwise + dvFuelUsage[1:p.n_scenarios, p.techs.gen, p.time_steps] >= 0 # Fuel burned by technology t in each time step [kWh] + binGenIsOnInTS[1:p.n_scenarios, p.techs.gen, p.time_steps], Bin # 1 If technology t is operating in time step h; 0 otherwise end end diff --git a/src/mpc/model_multinode.jl b/src/mpc/model_multinode.jl index 047f9f3a3..cef6bd247 100644 --- a/src/mpc/model_multinode.jl +++ b/src/mpc/model_multinode.jl @@ -60,12 +60,12 @@ function build_mpc!(m::JuMP.AbstractModel, ps::AbstractVector{MPCInputs}) for b in p.s.storage.types.all if p.s.storage.attr[b].size_kw == 0 || p.s.storage.attr[b].size_kwh == 0 - @constraint(m, [ts in p.time_steps], m[Symbol("dvStoredEnergy"*_n)][b, ts] == 0) - @constraint(m, [t in p.techs.elec, ts in p.time_steps_with_grid], - m[Symbol("dvProductionToStorage"*_n)][b, t, ts] == 0) - @constraint(m, [ts in p.time_steps], m[Symbol("dvDischargeFromStorage"*_n)][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvStoredEnergy"*_n)][s, b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.elec, ts in p.time_steps_with_grid], + m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts] == 0) if b in p.s.storage.types.elec - @constraint(m, [ts in p.time_steps], m[Symbol("dvGridToStorage"*_n)][b, ts] == 0) + @constraint(m, [s in 1:p.n_scenarios, ts in p.time_steps], m[Symbol("dvGridToStorage"*_n)][s, b, ts] == 0) end else add_general_storage_dispatch_constraints(m, p, b; _n=_n) @@ -88,8 +88,8 @@ function build_mpc!(m::JuMP.AbstractModel, ps::AbstractVector{MPCInputs}) add_production_constraints(m, p; _n=_n) if !isempty(p.techs.no_turndown) - @constraint(m, [t in p.techs.no_turndown, ts in p.time_steps], - m[Symbol("dvRatedProduction"*_n)][t,ts] == m[Symbol("dvSize"*_n)][t] + @constraint(m, [s in 1:p.n_scenarios, t in p.techs.no_turndown, ts in p.time_steps], + m[Symbol("dvRatedProduction"*_n)][s, t, ts] == m[Symbol("dvSize"*_n)][t] ) end @@ -147,10 +147,6 @@ end function add_variables!(m::JuMP.AbstractModel, ps::AbstractVector{MPCInputs}) - dvs_idx_on_techs_time_steps = String[ - "dvCurtail", - "dvRatedProduction", - ] dvs_idx_on_storagetypes_time_steps = String[ "dvDischargeFromStorage" ] @@ -159,10 +155,12 @@ function add_variables!(m::JuMP.AbstractModel, ps::AbstractVector{MPCInputs}) m[Symbol("dvSize"*_n)] = p.existing_sizes - for dv in dvs_idx_on_techs_time_steps - x = dv*_n - m[Symbol(x)] = @variable(m, [p.techs.all, p.time_steps], base_name=x, lower_bound=0) - end + # dvCurtail and dvRatedProduction need scenario dimension for OUU compatibility + dv = "dvCurtail"*_n + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) + + dv = "dvRatedProduction"*_n + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) m[Symbol("dvStoragePower"*_n)] = Dict{String, Float64}() m[Symbol("dvStorageEnergy"*_n)] = Dict{String, Float64}() @@ -173,33 +171,33 @@ function add_variables!(m::JuMP.AbstractModel, ps::AbstractVector{MPCInputs}) for dv in dvs_idx_on_storagetypes_time_steps x = dv*_n - m[Symbol(x)] = @variable(m, [p.s.storage.types.all, p.time_steps], base_name=x, lower_bound=0) + m[Symbol(x)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, p.time_steps], base_name=x, lower_bound=0) end dv = "dvGridToStorage"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.elec, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.elec, p.time_steps], base_name=dv, lower_bound=0) dv = "dvGridPurchase"*_n - m[Symbol(dv)] = @variable(m, [p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.time_steps, 1:p.s.electric_tariff.n_energy_tiers], base_name=dv, lower_bound=0) dv = "dvPeakDemandTOU"*_n - m[Symbol(dv)] = @variable(m, [p.ratchets, 1], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.ratchets, 1:p.s.electric_tariff.n_tou_demand_tiers], base_name=dv, lower_bound=0) dv = "dvPeakDemandMonth"*_n - m[Symbol(dv)] = @variable(m, [p.months, 1], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.months, 1:p.s.electric_tariff.n_monthly_demand_tiers], base_name=dv, lower_bound=0) dv = "dvProductionToStorage"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.all, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, p.techs.all, p.time_steps], base_name=dv, lower_bound=0) dv = "dvStoredEnergy"*_n - m[Symbol(dv)] = @variable(m, [p.s.storage.types.all, 0:p.time_steps[end]], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.s.storage.types.all, 0:p.time_steps[end]], base_name=dv, lower_bound=0) dv = "MinChargeAdder"*_n m[Symbol(dv)] = 0 if !isempty(p.s.electric_tariff.export_bins) dv = "dvProductionToGrid"*_n - m[Symbol(dv)] = @variable(m, [p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps], base_name=dv, lower_bound=0) + m[Symbol(dv)] = @variable(m, [1:p.n_scenarios, p.techs.elec, p.s.electric_tariff.export_bins, p.time_steps], base_name=dv, lower_bound=0) end ex_name = "TotalPerUnitProdOMCosts"*_n diff --git a/src/results/absorption_chiller.jl b/src/results/absorption_chiller.jl index 8bdd47069..b31d2f8df 100644 --- a/src/results/absorption_chiller.jl +++ b/src/results/absorption_chiller.jl @@ -26,29 +26,29 @@ function add_absorption_chiller_results(m::JuMP.AbstractModel, p::REoptInputs, d r["size_kw"] = value(sum(m[:dvSize][t] for t in p.techs.absorption_chiller)) r["size_ton"] = r["size_kw"] / KWH_THERMAL_PER_TONHOUR @expression(m, ABSORPCHLtoTESKW[ts in p.time_steps], - sum(m[:dvProductionToStorage][b,t,ts] for b in p.s.storage.types.cold, t in p.techs.absorption_chiller)) + sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s, b,t,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.cold, t in p.techs.absorption_chiller)) r["thermal_to_storage_series_ton"] = round.(value.(ABSORPCHLtoTESKW) ./ KWH_THERMAL_PER_TONHOUR, digits=5) @expression(m, ABSORPCHLtoLoadKW[ts in p.time_steps], - sum(m[:dvCoolingProduction][t,ts] for t in p.techs.absorption_chiller) + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t,ts] for s in 1:p.n_scenarios, t in p.techs.absorption_chiller) - ABSORPCHLtoTESKW[ts]) r["thermal_to_load_series_ton"] = round.(value.(ABSORPCHLtoLoadKW) ./ KWH_THERMAL_PER_TONHOUR, digits=5) @expression(m, ABSORPCHLThermalConsumptionSeriesKW[ts in p.time_steps], - sum(m[:dvCoolingProduction][t,ts] / p.thermal_cop[t] for t in p.techs.absorption_chiller)) + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t,ts] / p.thermal_cop[t] for s in 1:p.n_scenarios, t in p.techs.absorption_chiller)) r["thermal_consumption_series_mmbtu_per_hour"] = round.(value.(ABSORPCHLThermalConsumptionSeriesKW) ./ KWH_PER_MMBTU, digits=5) @expression(m, Year1ABSORPCHLThermalConsumptionKWH, - p.hours_per_time_step * sum(m[:dvCoolingProduction][t,ts] / p.thermal_cop[t] - for t in p.techs.absorption_chiller, ts in p.time_steps)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t,ts] / p.thermal_cop[t] + for s in 1:p.n_scenarios, t in p.techs.absorption_chiller, ts in p.time_steps)) r["annual_thermal_consumption_mmbtu"] = round(value(Year1ABSORPCHLThermalConsumptionKWH) / KWH_PER_MMBTU, digits=5) @expression(m, Year1ABSORPCHLThermalProdKWH, - p.hours_per_time_step * sum(m[:dvCoolingProduction][t, ts] - for t in p.techs.absorption_chiller, ts in p.time_steps)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t, ts] + for s in 1:p.n_scenarios, t in p.techs.absorption_chiller, ts in p.time_steps)) r["annual_thermal_production_tonhour"] = round(value(Year1ABSORPCHLThermalProdKWH) / KWH_THERMAL_PER_TONHOUR, digits=5) @expression(m, ABSORPCHLElectricConsumptionSeries[ts in p.time_steps], - sum(m[:dvCoolingProduction][t,ts] / p.cooling_cop[t][ts] for t in p.techs.absorption_chiller) ) + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t,ts] / p.cooling_cop[t][ts] for s in 1:p.n_scenarios, t in p.techs.absorption_chiller) ) r["electric_consumption_series_kw"] = round.(value.(ABSORPCHLElectricConsumptionSeries), digits=3) @expression(m, Year1ABSORPCHLElectricConsumption, - p.hours_per_time_step * sum(m[:dvCoolingProduction][t,ts] / p.cooling_cop[t][ts] - for t in p.techs.absorption_chiller, ts in p.time_steps)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, t,ts] / p.cooling_cop[t][ts] + for s in 1:p.n_scenarios, t in p.techs.absorption_chiller, ts in p.time_steps)) r["annual_electric_consumption_kwh"] = round(value(Year1ABSORPCHLElectricConsumption), digits=3) d["AbsorptionChiller"] = r diff --git a/src/results/ashp.jl b/src/results/ashp.jl index 9a34682c0..6b0c180a1 100644 --- a/src/results/ashp.jl +++ b/src/results/ashp.jl @@ -25,22 +25,22 @@ function add_ashp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r = Dict{String, Any}() r["size_ton"] = round(p.s.ashp.sizing_factor * value(m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"]) / KWH_THERMAL_PER_TONHOUR, digits=3) @expression(m, ASHPElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * sum(m[:dvHeatingProduction]["ASHPSpaceHeater",q,ts] for q in p.heating_loads) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPSpaceHeater",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads) / p.heating_cop["ASHPSpaceHeater"][ts] ) @expression(m, ASHPThermalProductionSeries[ts in p.time_steps], - sum(m[:dvHeatingProduction]["ASHPSpaceHeater",q,ts] for q in p.heating_loads)) # TODO add cooling + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPSpaceHeater",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) # TODO add cooling r["thermal_production_series_mmbtu_per_hour"] = round.(value.(ASHPThermalProductionSeries) / KWH_PER_MMBTU, digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) if !isempty(p.s.storage.types.hot) @expression(m, ASHPToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ASHPSpaceHeater",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ASHPSpaceHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) @expression(m, ASHPToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ASHPSpaceHeater",q,ts] for b in p.s.storage.types.hot) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ASHPSpaceHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot) ) else @expression(m, ASHPToHotTESKW[ts in p.time_steps], 0.0) @@ -48,19 +48,19 @@ function add_ashp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") end r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(ASHPToHotTESKW) / KWH_PER_MMBTU, digits=3) @expression(m, ASHPToWaste[ts in p.time_steps], - sum(m[:dvProductionToWaste]["ASHPSpaceHeater", q, ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ASHPSpaceHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) @expression(m, ASHPToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], - m[:dvProductionToWaste]["ASHPSpaceHeater",q,ts] + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ASHPSpaceHeater",q,ts] for s in 1:p.n_scenarios) ) @expression(m, ASHPToLoad[ts in p.time_steps], - sum(m[:dvHeatingProduction]["ASHPSpaceHeater", q, ts] for q in p.heating_loads) - ASHPToHotTESKW[ts] - ASHPToWaste[ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPSpaceHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) - ASHPToHotTESKW[ts] - ASHPToWaste[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ASHPToLoad) ./ KWH_PER_MMBTU, digits=3) if "SpaceHeating" in p.heating_loads && p.s.ashp.can_serve_space_heating @expression(m, ASHPToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["ASHPSpaceHeater","SpaceHeating",ts] - ASHPToHotTESByQualityKW["SpaceHeating",ts] - ASHPToWasteByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPSpaceHeater","SpaceHeating",ts] for s in 1:p.n_scenarios) - ASHPToHotTESByQualityKW["SpaceHeating",ts] - ASHPToWasteByQualityKW["SpaceHeating",ts] ) else @expression(m, ASHPToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -70,22 +70,22 @@ function add_ashp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") if "ASHPSpaceHeater" in p.techs.cooling && sum(p.s.cooling_load.loads_kw_thermal) > 0.0 @expression(m, ASHPtoColdTES[ts in p.time_steps], - sum(m[:dvProductionToStorage][b,"ASHPSpaceHeater",ts] for b in p.s.storage.types.cold) + sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s, b,"ASHPSpaceHeater",ts] for s in 1:p.n_scenarios, b in p.s.storage.types.cold) ) r["thermal_to_storage_series_ton"] = round.(value.(ASHPtoColdTES ./ KWH_THERMAL_PER_TONHOUR), digits=3) @expression(m, ASHPtoColdLoad[ts in p.time_steps], - sum(m[:dvCoolingProduction]["ASHPSpaceHeater", ts]) - ASHPtoColdTES[ts] + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, "ASHPSpaceHeater", ts] for s in 1:p.n_scenarios) - ASHPtoColdTES[ts] ) r["thermal_to_load_series_ton"] = round.(value.(ASHPtoColdLoad ./ KWH_THERMAL_PER_TONHOUR), digits=3) @expression(m, Year1ASHPColdThermalProd, - p.hours_per_time_step * sum(m[:dvCoolingProduction]["ASHPSpaceHeater", ts] for ts in p.time_steps) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, "ASHPSpaceHeater", ts] for s in 1:p.n_scenarios, ts in p.time_steps) ) r["annual_thermal_production_tonhour"] = round(value(Year1ASHPColdThermalProd / KWH_THERMAL_PER_TONHOUR), digits=3) @expression(m, ASHPColdElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * m[:dvCoolingProduction]["ASHPSpaceHeater",ts] / p.cooling_cop["ASHPSpaceHeater"][ts] + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s, "ASHPSpaceHeater",ts] for s in 1:p.n_scenarios) / p.cooling_cop["ASHPSpaceHeater"][ts] ) r["cooling_cop"] = p.cooling_cop["ASHPSpaceHeater"] r["cooling_cf"] = p.cooling_cf["ASHPSpaceHeater"] @@ -131,22 +131,22 @@ function add_ashp_wh_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n= r = Dict{String, Any}() r["size_ton"] = round(p.s.ashp_wh.sizing_factor * value(m[Symbol("dvSize"*_n)]["ASHPWaterHeater"]) / KWH_THERMAL_PER_TONHOUR, digits=3) @expression(m, ASHPWHElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * sum(m[:dvHeatingProduction][t,q,ts] / p.heating_cop[t][ts] - for q in p.heating_loads, t in p.techs.ashp_wh) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, t,q,ts] / p.heating_cop[t][ts] + for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.ashp_wh) ) @expression(m, ASHPWHThermalProductionSeries[ts in p.time_steps], - sum(m[:dvHeatingProduction][t,q,ts] for q in p.heating_loads, t in p.techs.ashp_wh)) + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, t,q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.ashp_wh)) r["thermal_production_series_mmbtu_per_hour"] = round.(value.(ASHPWHThermalProductionSeries) / KWH_PER_MMBTU, digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) if !isempty(p.s.storage.types.hot) @expression(m, ASHPWHToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ASHPWaterHeater",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ASHPWaterHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) @expression(m, ASHPWHToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ASHPWaterHeater",q,ts] for b in p.s.storage.types.hot) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ASHPWaterHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot) ) else @expression(m, ASHPWHToHotTESKW[ts in p.time_steps], 0.0) @@ -154,19 +154,19 @@ function add_ashp_wh_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n= end r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(ASHPWHToHotTESKW) / KWH_PER_MMBTU, digits=3) @expression(m, ASHPWHToWaste[ts in p.time_steps], - sum(m[:dvProductionToWaste]["ASHPWaterHeater", q, ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ASHPWaterHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) @expression(m, ASHPWHToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], - m[:dvProductionToWaste]["ASHPWaterHeater",q,ts] + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ASHPWaterHeater",q,ts] for s in 1:p.n_scenarios) ) @expression(m, ASHPWHToLoad[ts in p.time_steps], - sum(m[:dvHeatingProduction]["ASHPWaterHeater", q, ts] for q in p.heating_loads) - ASHPWHToHotTESKW[ts] - ASHPWHToWaste[ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPWaterHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) - ASHPWHToHotTESKW[ts] - ASHPWHToWaste[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ASHPWHToLoad) ./ KWH_PER_MMBTU, digits=3) if "DomesticHotWater" in p.heating_loads && p.s.ashp_wh.can_serve_dhw @expression(m, ASHPWHToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["ASHPWaterHeater","DomesticHotWater",ts] - ASHPWHToHotTESByQualityKW["DomesticHotWater",ts] - ASHPWHToWasteByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ASHPWaterHeater","DomesticHotWater",ts] for s in 1:p.n_scenarios) - ASHPWHToHotTESByQualityKW["DomesticHotWater",ts] - ASHPWHToWasteByQualityKW["DomesticHotWater",ts] ) else @expression(m, ASHPWHToDHWKW[ts in p.time_steps], 0.0) diff --git a/src/results/boiler.jl b/src/results/boiler.jl index 3b0beaee0..ebc7762a8 100644 --- a/src/results/boiler.jl +++ b/src/results/boiler.jl @@ -23,18 +23,18 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" r = Dict{String, Any}() r["size_mmbtu_per_hour"] = round(value(m[Symbol("dvSize"*_n)]["Boiler"]) / KWH_PER_MMBTU, digits=3) r["fuel_consumption_series_mmbtu_per_hour"] = - round.(value.(m[:dvFuelUsage]["Boiler", ts] for ts in p.time_steps) / KWH_PER_MMBTU, digits=3) + round.(sum(p.scenario_probabilities[s] * value(m[:dvFuelUsage][s, "Boiler", ts]) for s in 1:p.n_scenarios) / KWH_PER_MMBTU for ts in p.time_steps, digits=3) r["annual_fuel_consumption_mmbtu"] = round(sum(r["fuel_consumption_series_mmbtu_per_hour"]), digits=3) r["thermal_production_series_mmbtu_per_hour"] = - round.(sum(value.(m[:dvHeatingProduction]["Boiler", q, ts] for ts in p.time_steps) for q in p.heating_loads) ./ KWH_PER_MMBTU, digits=5) + round.([sum(p.scenario_probabilities[s] * value(sum(m[:dvHeatingProduction][s, "Boiler", q, ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) / KWH_PER_MMBTU for ts in p.time_steps], digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) if !isempty(p.s.storage.types.hot) @expression(m, NewBoilerToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"Boiler",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"Boiler",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) - @expression(m, NewBoilerToHotTESByQuality[q in p.heating_loads, ts in p.time_steps], sum(m[Symbol("dvHeatToStorage"*_n)][b,"Boiler",q,ts] for b in p.s.storage.types.hot)) + @expression(m, NewBoilerToHotTESByQuality[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s, b,"Boiler",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot)) else NewBoilerToHotTESKW = zeros(length(p.time_steps)) @expression(m, NewBoilerToHotTESByQuality[q in p.heating_loads, ts in p.time_steps], 0.0) @@ -42,8 +42,8 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(NewBoilerToHotTESKW / KWH_PER_MMBTU), digits=3) if !isempty(p.techs.steam_turbine) && p.s.boiler.can_supply_steam_turbine - @expression(m, NewBoilerToSteamTurbine[ts in p.time_steps], sum(m[:dvThermalToSteamTurbine]["Boiler",q,ts] for q in p.heating_loads)) - @expression(m, NewBoilerToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], m[Symbol("dvThermalToSteamTurbine"*_n)]["Boiler",q,ts]) + @expression(m, NewBoilerToSteamTurbine[ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s, "Boiler",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) + @expression(m, NewBoilerToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[Symbol("dvThermalToSteamTurbine"*_n)][s, "Boiler",q,ts] for s in 1:p.n_scenarios)) else NewBoilerToSteamTurbine = zeros(length(p.time_steps)) @expression(m, NewBoilerToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], 0.0) @@ -51,13 +51,13 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" r["thermal_to_steamturbine_series_mmbtu_per_hour"] = round.(value.(NewBoilerToSteamTurbine), digits=3) BoilerToLoad = @expression(m, [ts in p.time_steps], - sum(value.(m[:dvHeatingProduction]["Boiler", q, ts]) for q in p.heating_loads) - NewBoilerToHotTESKW[ts] - NewBoilerToSteamTurbine[ts] + sum(p.scenario_probabilities[s] * value(m[:dvHeatingProduction][s, "Boiler", q, ts]) for s in 1:p.n_scenarios, q in p.heating_loads) - NewBoilerToHotTESKW[ts] - NewBoilerToSteamTurbine[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(BoilerToLoad / KWH_PER_MMBTU), digits=3) if "DomesticHotWater" in p.heating_loads && p.s.boiler.can_serve_dhw @expression(m, NewBoilerToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["Boiler","DomesticHotWater",ts] - NewBoilerToHotTESByQuality["DomesticHotWater",ts] - NewBoilerToSteamTurbineByQuality["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "Boiler","DomesticHotWater",ts] for s in 1:p.n_scenarios) - NewBoilerToHotTESByQuality["DomesticHotWater",ts] - NewBoilerToSteamTurbineByQuality["DomesticHotWater",ts] ) else @expression(m, NewBoilerToDHWKW[ts in p.time_steps], 0.0) @@ -66,7 +66,7 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" if "SpaceHeating" in p.heating_loads && p.s.boiler.can_serve_space_heating @expression(m, NewBoilerToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["Boiler","SpaceHeating",ts] - NewBoilerToHotTESByQuality["SpaceHeating",ts] - NewBoilerToSteamTurbineByQuality["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "Boiler","SpaceHeating",ts] for s in 1:p.n_scenarios) - NewBoilerToHotTESByQuality["SpaceHeating",ts] - NewBoilerToSteamTurbineByQuality["SpaceHeating",ts] ) else @expression(m, NewBoilerToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -75,7 +75,7 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" if "ProcessHeat" in p.heating_loads && p.s.boiler.can_serve_process_heat @expression(m, NewBoilerToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["Boiler","ProcessHeat",ts] - NewBoilerToHotTESByQuality["ProcessHeat",ts] - NewBoilerToSteamTurbineByQuality["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "Boiler","ProcessHeat",ts] for s in 1:p.n_scenarios) - NewBoilerToHotTESByQuality["ProcessHeat",ts] - NewBoilerToSteamTurbineByQuality["ProcessHeat",ts] ) else @expression(m, NewBoilerToProcessHeatKW[ts in p.time_steps], 0.0) @@ -83,7 +83,7 @@ function add_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n=" r["thermal_to_process_heat_load_series_mmbtu_per_hour"] = round.(value.(NewBoilerToProcessHeatKW ./ KWH_PER_MMBTU), digits=5) lifecycle_fuel_cost = p.pwf_fuel["Boiler"] * value( - sum(m[:dvFuelUsage]["Boiler", ts] * p.fuel_cost_per_kwh["Boiler"][ts] for ts in p.time_steps) + sum(p.scenario_probabilities[s] * m[:dvFuelUsage][s, "Boiler", ts] * p.fuel_cost_per_kwh["Boiler"][ts] for s in 1:p.n_scenarios, ts in p.time_steps) ) r["lifecycle_fuel_cost_after_tax"] = round(lifecycle_fuel_cost * (1 - p.s.financial.offtaker_tax_rate_fraction), digits=3) r["year_one_fuel_cost_before_tax"] = round(lifecycle_fuel_cost / p.pwf_fuel["Boiler"], digits=3) diff --git a/src/results/chp.jl b/src/results/chp.jl index 58f302804..aeca7d247 100644 --- a/src/results/chp.jl +++ b/src/results/chp.jl @@ -34,80 +34,75 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r = Dict{String, Any}() r["size_kw"] = value(sum(m[Symbol("dvSize"*_n)][t] for t in p.techs.chp)) r["size_supplemental_firing_kw"] = value(sum(m[Symbol("dvSupplementaryFiringSize"*_n)][t] for t in p.techs.chp)) - @expression(m, CHPFuelUsedKWH, sum(m[Symbol("dvFuelUsage"*_n)][t, ts] for t in p.techs.chp, ts in p.time_steps)) - r["annual_fuel_consumption_mmbtu"] = round(value(CHPFuelUsedKWH) / KWH_PER_MMBTU, digits=3) - @expression(m, Year1CHPElecProd, - p.hours_per_time_step * sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] - for t in p.techs.chp, ts in p.time_steps)) - r["annual_electric_production_kwh"] = round(value(Year1CHPElecProd), digits=3) - - @expression(m, CHPThermalProdKW[ts in p.time_steps], - sum(sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] - m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for q in p.heating_loads) + - m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] for t in p.techs.chp)) - - r["thermal_production_series_mmbtu_per_hour"] = round.(value.(CHPThermalProdKW) / KWH_PER_MMBTU, digits=5) + CHPFuelUsedKWH = sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvFuelUsage"*_n)][s, t, ts]) for t in p.techs.chp, ts in p.time_steps) for s in 1:p.n_scenarios) + r["annual_fuel_consumption_mmbtu"] = round(CHPFuelUsedKWH / KWH_PER_MMBTU, digits=3) + Year1CHPElecProd = p.hours_per_time_step * sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) * p.production_factor[t, ts] + for t in p.techs.chp, ts in p.time_steps) for s in 1:p.n_scenarios) + r["annual_electric_production_kwh"] = round(Year1CHPElecProd, digits=3) + CHPThermalProdKW = [sum(p.scenario_probabilities[s] * (sum(value(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts]) - value(m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts]) for q in p.heating_loads) + + value(m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts])) for t in p.techs.chp, s in 1:p.n_scenarios) for ts in p.time_steps] + r["thermal_production_series_mmbtu_per_hour"] = round.(CHPThermalProdKW / KWH_PER_MMBTU, digits=5) r["annual_thermal_production_mmbtu"] = round(p.hours_per_time_step * sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) - @expression(m, CHPElecProdTotal[ts in p.time_steps], - sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] for t in p.techs.chp)) - r["electric_production_series_kw"] = round.(value.(CHPElecProdTotal), digits=3) + CHPElecProdTotal = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) * p.production_factor[t, ts] for t in p.techs.chp) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["electric_production_series_kw"] = round.(CHPElecProdTotal, digits=3) # Electric dispatch breakdown + CHPtoGrid = zeros(length(p.time_steps)) if !isempty(p.s.electric_tariff.export_bins) - @expression(m, CHPtoGrid[ts in p.time_steps], sum(m[Symbol("dvProductionToGrid"*_n)][t,u,ts] - for t in p.techs.chp, u in p.export_bins_by_tech[t])) - else - CHPtoGrid = zeros(length(p.time_steps)) + for t in p.techs.chp + for u in p.export_bins_by_tech[t] + CHPtoGrid .+= [p.scenario_probabilities[s] * value(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts]) for s in 1:p.n_scenarios for ts in p.time_steps] + end + end end - r["electric_to_grid_series_kw"] = round.(value.(CHPtoGrid), digits=3) + r["electric_to_grid_series_kw"] = round.(CHPtoGrid, digits=3) if !isempty(p.s.storage.types.elec) - @expression(m, CHPtoBatt[ts in p.time_steps], - sum(m[Symbol("dvProductionToStorage"*_n)]["ElectricStorage",t,ts] for t in p.techs.chp)) + CHPtoBatt = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvProductionToStorage"*_n)][s, "ElectricStorage",t,ts]) for t in p.techs.chp) for s in 1:p.n_scenarios) for ts in p.time_steps] else CHPtoBatt = zeros(length(p.time_steps)) end - r["electric_to_storage_series_kw"] = round.(value.(CHPtoBatt), digits=3) - @expression(m, CHPtoLoad[ts in p.time_steps], - sum(m[Symbol("dvRatedProduction"*_n)][t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] - for t in p.techs.chp) - CHPtoBatt[ts] - CHPtoGrid[ts]) - r["electric_to_load_series_kw"] = round.(value.(CHPtoLoad), digits=3) + r["electric_to_storage_series_kw"] = round.(CHPtoBatt, digits=3) + CHPtoLoad = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t, ts]) * p.production_factor[t, ts] * p.levelization_factor[t] + for t in p.techs.chp) for s in 1:p.n_scenarios) - CHPtoBatt[ts] - CHPtoGrid[ts] for ts in p.time_steps] + r["electric_to_load_series_kw"] = round.(CHPtoLoad, digits=3) # Thermal dispatch breakdown if !isempty(p.s.storage.types.hot) @expression(m, CHPToHotTES[ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b, t, q, ts] for b in p.s.storage.types.hot, t in p.techs.chp, q in p.heating_loads)) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s, b, t, q, ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, t in p.techs.chp, q in p.heating_loads)) @expression(m, CHPToHotTESByQuality[q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)][b, t, q, ts] for b in p.s.storage.types.hot, t in p.techs.chp)) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s, b, t, q, ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, t in p.techs.chp)) else @expression(m, CHPToHotTES[ts in p.time_steps], 0.0) @expression(m, CHPToHotTESByQuality[q in p.heating_loads, ts in p.time_steps], 0.0) end r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(CHPToHotTES / KWH_PER_MMBTU), digits=5) @expression(m, CHPThermalToWasteKW[ts in p.time_steps], - sum(m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for q in p.heating_loads, t in p.techs.chp)) + sum(p.scenario_probabilities[s] * m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.chp)) @expression(m, CHPThermalToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvProductionToWaste"*_n)][t,q,ts] for t in p.techs.chp)) + sum(p.scenario_probabilities[s] * m[Symbol("dvProductionToWaste"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, t in p.techs.chp)) r["thermal_curtailed_series_mmbtu_per_hour"] = round.(value.(CHPThermalToWasteKW) / KWH_PER_MMBTU, digits=5) if !isempty(p.techs.steam_turbine) && p.s.chp.can_supply_steam_turbine - @expression(m, CHPToSteamTurbineKW[ts in p.time_steps], sum(m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] for t in p.techs.chp, q in p.heating_loads)) - @expression(m, CHPToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], sum(m[Symbol("dvThermalToSteamTurbine"*_n)][t,q,ts] for t in p.techs.chp)) + @expression(m, CHPToSteamTurbineKW[ts in p.time_steps], sum(p.scenario_probabilities[s] * m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, t in p.techs.chp, q in p.heating_loads)) + @expression(m, CHPToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[Symbol("dvThermalToSteamTurbine"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, t in p.techs.chp)) else CHPToSteamTurbineKW = zeros(length(p.time_steps)) @expression(m, CHPToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], 0.0) end r["thermal_to_steamturbine_series_mmbtu_per_hour"] = round.(value.(CHPToSteamTurbineKW) / KWH_PER_MMBTU, digits=5) @expression(m, CHPThermalToLoadKW[ts in p.time_steps], - sum(sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) + m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] - for t in p.techs.chp) - CHPToHotTES[ts] - CHPToSteamTurbineKW[ts] - CHPThermalToWasteKW[ts]) + sum(p.scenario_probabilities[s] * (sum(m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for q in p.heating_loads) + m[Symbol("dvSupplementaryThermalProduction"*_n)][s,t,ts]) + for s in 1:p.n_scenarios, t in p.techs.chp) - CHPToHotTES[ts] - CHPToSteamTurbineKW[ts] - CHPThermalToWasteKW[ts]) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(CHPThermalToLoadKW ./ KWH_PER_MMBTU), digits=5) CHPToLoadKW = @expression(m, [ts in p.time_steps], - sum(value.(m[:dvHeatingProduction]["CHP",q,ts] for q in p.heating_loads)) - CHPToHotTES[ts] - CHPToSteamTurbineKW[ts] + sum(p.scenario_probabilities[s] * value(sum(m[:dvHeatingProduction][s,"CHP",q,ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) - CHPToHotTES[ts] - CHPToSteamTurbineKW[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(CHPThermalToLoadKW ./ KWH_PER_MMBTU), digits=5) if "DomesticHotWater" in p.heating_loads && p.s.chp.can_serve_dhw @expression(m, CHPToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["CHP","DomesticHotWater",ts] - CHPToHotTESByQuality["DomesticHotWater",ts] - CHPToSteamTurbineByQualityKW["DomesticHotWater",ts] - CHPThermalToWasteByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CHP","DomesticHotWater",ts] for s in 1:p.n_scenarios) - CHPToHotTESByQuality["DomesticHotWater",ts] - CHPToSteamTurbineByQualityKW["DomesticHotWater",ts] - CHPThermalToWasteByQualityKW["DomesticHotWater",ts] ) else @expression(m, CHPToDHWKW[ts in p.time_steps], 0.0) @@ -116,7 +111,7 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") if "SpaceHeating" in p.heating_loads && p.s.chp.can_serve_space_heating @expression(m, CHPToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["CHP","SpaceHeating",ts] - CHPToHotTESByQuality["SpaceHeating",ts] - CHPToSteamTurbineByQualityKW["SpaceHeating",ts] - CHPThermalToWasteByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CHP","SpaceHeating",ts] for s in 1:p.n_scenarios) - CHPToHotTESByQuality["SpaceHeating",ts] - CHPToSteamTurbineByQualityKW["SpaceHeating",ts] - CHPThermalToWasteByQualityKW["SpaceHeating",ts] ) else @expression(m, CHPToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -125,7 +120,7 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") if "ProcessHeat" in p.heating_loads && p.s.chp.can_serve_process_heat @expression(m, CHPToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["CHP","ProcessHeat",ts] - CHPToHotTESByQuality["ProcessHeat",ts] - CHPToSteamTurbineByQualityKW["ProcessHeat",ts] - CHPThermalToWasteByQualityKW["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CHP","ProcessHeat",ts] for s in 1:p.n_scenarios) - CHPToHotTESByQuality["ProcessHeat",ts] - CHPToSteamTurbineByQualityKW["ProcessHeat",ts] - CHPThermalToWasteByQualityKW["ProcessHeat",ts] ) else @expression(m, CHPToProcessHeatKW[ts in p.time_steps], 0.0) diff --git a/src/results/cst.jl b/src/results/cst.jl index 05d768f31..20d5c2976 100644 --- a/src/results/cst.jl +++ b/src/results/cst.jl @@ -25,27 +25,27 @@ function add_concentrating_solar_results(m::JuMP.AbstractModel, p::REoptInputs, r["size_kw"] = round(value(m[Symbol("dvSize"*_n)]["CST"]), digits=3) r["size_mmbtu_per_hour"] = round(value(m[Symbol("dvSize"*_n)]["CST"]) / KWH_PER_MMBTU, digits=3) @expression(m, CSTElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * sum(m[:dvHeatingProduction]["CST",q,ts] / p.heating_cop["CST"][ts] - for q in p.heating_loads)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST",q,ts] / p.heating_cop["CST"][ts] + for s in 1:p.n_scenarios, q in p.heating_loads)) r["electric_consumption_series_kw"] = round.(value.(CSTElectricConsumptionSeries), digits=3) r["annual_electric_consumption_kwh"] = sum(r["electric_consumption_series_kw"]) @expression(m, CSTThermalProductionSeries[ts in p.time_steps], - sum(m[:dvHeatingProduction]["CST",q,ts] for q in p.heating_loads)) + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) r["thermal_production_series_mmbtu_per_hour"] = round.(value.(CSTThermalProductionSeries) / KWH_PER_MMBTU, digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) if !isempty(p.s.storage.types.hot) @expression(m, CSTToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"CST",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s,b,"CST",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) @expression(m, CSTToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"CST",q,ts] for b in p.s.storage.types.hot) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s,b,"CST",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot) ) if "HighTempThermalStorage" in p.s.storage.types.hot @expression(m, CSTToHotSensibleTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage]["HighTempThermalStorage","CST",q,ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s,"HighTempThermalStorage","CST",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) else @expression(m, CSTToHotSensibleTESKW[ts in p.time_steps], 0.0) @@ -59,8 +59,8 @@ function add_concentrating_solar_results(m::JuMP.AbstractModel, p::REoptInputs, r["thermal_to_high_temp_thermal_storage_series_mmbtu_per_hour"] = round.(value.(CSTToHotSensibleTESKW) / KWH_PER_MMBTU, digits=3) if !isempty(p.techs.steam_turbine) && p.s.cst.can_supply_steam_turbine - @expression(m, CSTToSteamTurbine[ts in p.time_steps], sum(m[:dvThermalToSteamTurbine]["CST",q,ts] for q in p.heating_loads)) - @expression(m, CSTToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], m[:dvThermalToSteamTurbine]["CST",q,ts]) + @expression(m, CSTToSteamTurbine[ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s,"CST",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) + @expression(m, CSTToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s,"CST",q,ts] for s in 1:p.n_scenarios)) else CSTToSteamTurbine = zeros(length(p.time_steps)) @expression(m, CSTToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], 0.0) @@ -68,21 +68,21 @@ function add_concentrating_solar_results(m::JuMP.AbstractModel, p::REoptInputs, r["thermal_to_steamturbine_series_mmbtu_per_hour"] = round.(value.(CSTToSteamTurbine) / KWH_PER_MMBTU, digits=3) @expression(m, CSTToWaste[ts in p.time_steps], - sum(m[:dvProductionToWaste]["CST", q, ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s,"CST", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) @expression(m, CSTToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], - m[:dvProductionToWaste]["CST", q, ts] + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s,"CST", q, ts] for s in 1:p.n_scenarios) ) r["thermal_curtailed_series_mmbtu_per_hour"] = round.(value.(CSTToWaste) / KWH_PER_MMBTU, digits=3) @expression(m, CSTToLoad[ts in p.time_steps], - sum(m[:dvHeatingProduction]["CST", q, ts] for q in p.heating_loads) - CSTToHotTESKW[ts] - CSTToSteamTurbine[ts] - CSTToWaste[ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) - CSTToHotTESKW[ts] - CSTToSteamTurbine[ts] - CSTToWaste[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(CSTToLoad) / KWH_PER_MMBTU, digits=3) if "DomesticHotWater" in p.heating_loads && p.s.cst.can_serve_dhw @expression(m, CSTToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["CST","DomesticHotWater",ts] - CSTToHotTESByQualityKW["DomesticHotWater",ts] - CSTToSteamTurbineByQuality["DomesticHotWater",ts] - CSTToWasteByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST","DomesticHotWater",ts] for s in 1:p.n_scenarios) - CSTToHotTESByQualityKW["DomesticHotWater",ts] - CSTToSteamTurbineByQuality["DomesticHotWater",ts] - CSTToWasteByQualityKW["DomesticHotWater",ts] ) else @expression(m, CSTToDHWKW[ts in p.time_steps], 0.0) @@ -91,7 +91,7 @@ function add_concentrating_solar_results(m::JuMP.AbstractModel, p::REoptInputs, if "SpaceHeating" in p.heating_loads && p.s.cst.can_serve_space_heating @expression(m, CSTToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["CST","SpaceHeating",ts] - CSTToHotTESByQualityKW["SpaceHeating",ts] - CSTToSteamTurbineByQuality["SpaceHeating",ts] - CSTToWasteByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST","SpaceHeating",ts] for s in 1:p.n_scenarios) - CSTToHotTESByQualityKW["SpaceHeating",ts] - CSTToSteamTurbineByQuality["SpaceHeating",ts] - CSTToWasteByQualityKW["SpaceHeating",ts] ) else @expression(m, CSTToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -100,7 +100,7 @@ function add_concentrating_solar_results(m::JuMP.AbstractModel, p::REoptInputs, if "ProcessHeat" in p.heating_loads && p.s.cst.can_serve_process_heat @expression(m, CSTToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["CST","ProcessHeat",ts] - CSTToHotTESByQualityKW["ProcessHeat",ts] - CSTToSteamTurbineByQuality["ProcessHeat",ts] - CSTToWasteByQualityKW["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,"CST","ProcessHeat",ts] for s in 1:p.n_scenarios) - CSTToHotTESByQualityKW["ProcessHeat",ts] - CSTToSteamTurbineByQuality["ProcessHeat",ts] - CSTToWasteByQualityKW["ProcessHeat",ts] ) else @expression(m, CSTToProcessHeatKW[ts in p.time_steps], 0.0) diff --git a/src/results/electric_heater.jl b/src/results/electric_heater.jl index afa289d4a..19761a273 100644 --- a/src/results/electric_heater.jl +++ b/src/results/electric_heater.jl @@ -21,27 +21,27 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r = Dict{String, Any}() r["size_mmbtu_per_hour"] = round(value(m[Symbol("dvSize"*_n)]["ElectricHeater"]) / KWH_PER_MMBTU, digits=3) @expression(m, ElectricHeaterElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * sum(m[:dvHeatingProduction][t,q,ts] / p.heating_cop[t][ts] - for q in p.heating_loads, t in p.techs.electric_heater)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, t,q,ts] / p.heating_cop[t][ts] + for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.electric_heater)) r["electric_consumption_series_kw"] = round.(value.(ElectricHeaterElectricConsumptionSeries), digits=3) r["annual_electric_consumption_kwh"] = sum(r["electric_consumption_series_kw"]) @expression(m, ElectricHeaterThermalProductionSeries[ts in p.time_steps], - sum(m[:dvHeatingProduction][t,q,ts] for q in p.heating_loads, t in p.techs.electric_heater)) + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, t,q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.electric_heater)) r["thermal_production_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterThermalProductionSeries) / KWH_PER_MMBTU, digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) if !isempty(p.s.storage.types.hot) @expression(m, ElectricHeaterToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ElectricHeater",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ElectricHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) @expression(m, ElectricHeaterToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ElectricHeater",q,ts] for b in p.s.storage.types.hot) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ElectricHeater",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot) ) if "HighTempThermalStorage" in p.s.storage.types.hot @expression(m, ElectricHeaterToHotSensibleTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage]["HighTempThermalStorage","ElectricHeater",q,ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, "HighTempThermalStorage","ElectricHeater",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) else @expression(m, ElectricHeaterToHotSensibleTESKW[ts in p.time_steps], 0.0) @@ -55,8 +55,8 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["thermal_to_high_temp_thermal_storage_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterToHotSensibleTESKW) / KWH_PER_MMBTU, digits=3) if !isempty(p.techs.steam_turbine) && p.s.electric_heater.can_supply_steam_turbine - @expression(m, ElectricHeaterToSteamTurbine[ts in p.time_steps], sum(m[:dvThermalToSteamTurbine]["ElectricHeater",q,ts] for q in p.heating_loads)) - @expression(m, ElectricHeaterToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], m[:dvThermalToSteamTurbine]["ElectricHeater",q,ts]) + @expression(m, ElectricHeaterToSteamTurbine[ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s, "ElectricHeater",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) + @expression(m, ElectricHeaterToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s, "ElectricHeater",q,ts] for s in 1:p.n_scenarios)) else ElectricHeaterToSteamTurbine = zeros(length(p.time_steps)) @expression(m, ElectricHeaterToSteamTurbineByQuality[q in p.heating_loads, ts in p.time_steps], 0.0) @@ -64,20 +64,20 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["thermal_to_steamturbine_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterToSteamTurbine) / KWH_PER_MMBTU, digits=3) @expression(m, ElectricHeaterToWaste[ts in p.time_steps], - sum(m[:dvProductionToWaste]["ElectricHeater", q, ts] for q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ElectricHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) ) @expression(m, ElectricHeaterToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], - m[:dvProductionToWaste]["ElectricHeater",q,ts] + sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s, "ElectricHeater",q,ts] for s in 1:p.n_scenarios) ) @expression(m, ElectricHeaterToLoad[ts in p.time_steps], - sum(m[:dvHeatingProduction]["ElectricHeater", q, ts] for q in p.heating_loads) - ElectricHeaterToHotTESKW[ts] - ElectricHeaterToSteamTurbine[ts] - ElectricHeaterToWaste[ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ElectricHeater", q, ts] for s in 1:p.n_scenarios, q in p.heating_loads) - ElectricHeaterToHotTESKW[ts] - ElectricHeaterToSteamTurbine[ts] - ElectricHeaterToWaste[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterToLoad) / KWH_PER_MMBTU, digits=3) if "DomesticHotWater" in p.heating_loads && p.s.electric_heater.can_serve_dhw @expression(m, ElectricHeaterToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","DomesticHotWater",ts] - ElectricHeaterToHotTESByQualityKW["DomesticHotWater",ts] - ElectricHeaterToSteamTurbineByQuality["DomesticHotWater",ts] - ElectricHeaterToWasteByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ElectricHeater","DomesticHotWater",ts] for s in 1:p.n_scenarios) - ElectricHeaterToHotTESByQualityKW["DomesticHotWater",ts] - ElectricHeaterToSteamTurbineByQuality["DomesticHotWater",ts] - ElectricHeaterToWasteByQualityKW["DomesticHotWater",ts] ) else @expression(m, ElectricHeaterToDHWKW[ts in p.time_steps], 0.0) @@ -86,7 +86,7 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "SpaceHeating" in p.heating_loads && p.s.electric_heater.can_serve_space_heating @expression(m, ElectricHeaterToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","SpaceHeating",ts] - ElectricHeaterToHotTESByQualityKW["SpaceHeating",ts] - ElectricHeaterToSteamTurbineByQuality["SpaceHeating",ts] - ElectricHeaterToWasteByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ElectricHeater","SpaceHeating",ts] for s in 1:p.n_scenarios) - ElectricHeaterToHotTESByQualityKW["SpaceHeating",ts] - ElectricHeaterToSteamTurbineByQuality["SpaceHeating",ts] - ElectricHeaterToWasteByQualityKW["SpaceHeating",ts] ) else @expression(m, ElectricHeaterToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -95,7 +95,7 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "ProcessHeat" in p.heating_loads && p.s.electric_heater.can_serve_process_heat @expression(m, ElectricHeaterToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","ProcessHeat",ts] - ElectricHeaterToHotTESByQualityKW["ProcessHeat",ts] - ElectricHeaterToSteamTurbineByQuality["ProcessHeat",ts] - ElectricHeaterToWasteByQualityKW["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ElectricHeater","ProcessHeat",ts] for s in 1:p.n_scenarios) - ElectricHeaterToHotTESByQualityKW["ProcessHeat",ts] - ElectricHeaterToSteamTurbineByQuality["ProcessHeat",ts] - ElectricHeaterToWasteByQualityKW["ProcessHeat",ts] ) else @expression(m, ElectricHeaterToProcessHeatKW[ts in p.time_steps], 0.0) diff --git a/src/results/electric_load.jl b/src/results/electric_load.jl index 457ef4abe..43bc5e847 100644 --- a/src/results/electric_load.jl +++ b/src/results/electric_load.jl @@ -51,8 +51,8 @@ function add_electric_load_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dic sum(p.s.electric_load.critical_loads_kw)) r["offgrid_load_met_fraction"] = round(value(LoadMetPct), digits=6) - r["offgrid_annual_oper_res_required_series_kwh"] = round.(value.(m[:OpResRequired][ts] for ts in p.time_steps_without_grid), digits=3) - r["offgrid_annual_oper_res_provided_series_kwh"] = round.(value.(m[:OpResProvided][ts] for ts in p.time_steps_without_grid), digits=3) + r["offgrid_annual_oper_res_required_series_kwh"] = round.([sum(p.scenario_probabilities[s] * value(m[:OpResRequired][s, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps_without_grid], digits=3) + r["offgrid_annual_oper_res_provided_series_kwh"] = round.([sum(p.scenario_probabilities[s] * value(m[:OpResProvided][s, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps_without_grid], digits=3) end d["ElectricLoad"] = r diff --git a/src/results/electric_storage.jl b/src/results/electric_storage.jl index 809b55816..58bd52233 100644 --- a/src/results/electric_storage.jl +++ b/src/results/electric_storage.jl @@ -27,11 +27,12 @@ function add_electric_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d:: r["size_kw"] = round(value(m[Symbol("dvStoragePower"*_n)][b]), digits=2) if r["size_kwh"] != 0 - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) - r["soc_series_fraction"] = round.(value.(soc) ./ r["size_kwh"], digits=3) + # Compute expected value across scenarios for SOC and discharge + soc = (sum(p.scenario_probabilities[s] * value(m[Symbol("dvStoredEnergy"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps) + r["soc_series_fraction"] = round.(collect(soc) ./ r["size_kwh"], digits=3) - discharge = (m[Symbol("dvDischargeFromStorage"*_n)][b, ts] for ts in p.time_steps) - r["storage_to_load_series_kw"] = round.(value.(discharge), digits=3) + discharge = (sum(p.scenario_probabilities[s] * value(m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps) + r["storage_to_load_series_kw"] = round.(collect(discharge), digits=3) r["initial_capital_cost"] = r["size_kwh"] * p.s.storage.attr[b].installed_cost_per_kwh + r["size_kw"] * p.s.storage.attr[b].installed_cost_per_kw + @@ -77,10 +78,10 @@ MPC `ElectricStorage` results keys: function add_electric_storage_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict, b::String; _n="") r = Dict{String, Any}() - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) + soc = (m[Symbol("dvStoredEnergy"*_n)][1, b, ts] for ts in p.time_steps) r["soc_series_fraction"] = round.(value.(soc) ./ p.s.storage.attr[b].size_kwh, digits=3) - discharge = (m[Symbol("dvDischargeFromStorage"*_n)][b, ts] for ts in p.time_steps) + discharge = (m[Symbol("dvDischargeFromStorage"*_n)][1, b, ts] for ts in p.time_steps) r["to_load_series_kw"] = round.(value.(discharge), digits=3) d[b] = r diff --git a/src/results/electric_tariff.jl b/src/results/electric_tariff.jl index e3451c874..9f2fe6fac 100644 --- a/src/results/electric_tariff.jl +++ b/src/results/electric_tariff.jl @@ -55,7 +55,7 @@ function add_electric_tariff_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r = Dict{String, Any}() m[Symbol("Year1UtilityEnergy"*_n)] = p.hours_per_time_step * - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) + sum(p.scenario_probabilities[s] * m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) r["lifecycle_energy_cost_after_tax"] = round(value(m[Symbol("TotalEnergyChargesUtil"*_n)]) * (1 - p.s.financial.offtaker_tax_rate_fraction), digits=2) r["year_one_energy_cost_before_tax"] = round(value(m[Symbol("TotalEnergyChargesUtil"*_n)]) / p.pwf_e, digits=2) @@ -139,10 +139,11 @@ function add_electric_tariff_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["tou_demand_rate_tier_limits"][string("Tier_", idx)] = col end - # Grid to load. + # Grid to load - compute expected value across scenarios r["energy_cost_series_before_tax"] = Dict() for (idx,col) in enumerate(eachcol(p.s.electric_tariff.energy_rates)) - r["energy_cost_series_before_tax"][string("Tier_", idx)] = col.*collect(value.(m[Symbol("dvGridPurchase"*_n)][:,idx])).* p.hours_per_time_step + expected_grid_purchase = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvGridPurchase"*_n)][s, ts, idx]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["energy_cost_series_before_tax"][string("Tier_", idx)] = col .* expected_grid_purchase .* p.hours_per_time_step end if Dates.isleapyear(p.s.electric_load.year) # end dr on Dec 30th 11:59 pm. @@ -163,11 +164,12 @@ function add_electric_tariff_results(m::JuMP.AbstractModel, p::REoptInputs, d::D push!(r["monthly_energy_cost_series_before_tax"], monthly_sum) end - # monthly demand charges paid to utility. + # monthly demand charges paid to utility - compute expected value across scenarios r["monthly_facility_demand_cost_series_before_tax"] = zeros(12) if !isempty(p.s.electric_tariff.monthly_demand_rates) for (idx,col) in enumerate(eachcol(p.s.electric_tariff.monthly_demand_rates)) - r["monthly_facility_demand_cost_series_before_tax"] .+= col.*collect(value.(m[Symbol("dvPeakDemandMonth"*_n)][:,idx])) + expected_peak_demand = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvPeakDemandMonth"*_n)][s, mth, idx]) for s in 1:p.n_scenarios) for mth in 1:12] + r["monthly_facility_demand_cost_series_before_tax"] .+= col .* expected_peak_demand end end @@ -180,10 +182,12 @@ function add_electric_tariff_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["tou_demand_metrics"]["demand_charge_before_tax"] = [] tou_demand_charges = Dict() for tier in 1:p.s.electric_tariff.n_tou_demand_tiers + # Compute expected peak demand across scenarios for this tier + expected_tou_peaks = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvPeakDemandTOU"*_n)][s, r, tier]) for s in 1:p.n_scenarios) for r in 1:length(p.ratchets)] for (a,b,c) in zip( p.s.electric_tariff.tou_demand_ratchet_time_steps, p.s.electric_tariff.tou_demand_rates[:,tier], - value.(m[Symbol("dvPeakDemandTOU"*_n)][:,tier])) + expected_tou_peaks) idx = a[1] + ts_shift # DateTime element to inspect for month determination. Shift ts by a day in case of leap year to capture December TOU ratchets. @@ -253,7 +257,7 @@ MPC `ElectricTariff` results keys: function add_electric_tariff_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict; _n="") r = Dict{String, Any}() m[Symbol("energy_purchased"*_n)] = p.hours_per_time_step * - sum(m[Symbol("dvGridPurchase"*_n)][ts] for ts in p.time_steps) + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) r["energy_cost"] = round(value(m[Symbol("TotalEnergyChargesUtil"*_n)]), digits=2) diff --git a/src/results/electric_utility.jl b/src/results/electric_utility.jl index 79ab20507..df76fa02e 100644 --- a/src/results/electric_utility.jl +++ b/src/results/electric_utility.jl @@ -42,24 +42,20 @@ function add_electric_utility_results(m::JuMP.AbstractModel, p::AbstractInputs, end end - Year1UtilityEnergy = p.hours_per_time_step * sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] - for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) - r["annual_energy_supplied_kwh"] = round(value(Year1UtilityEnergy), digits=2) - - if !isempty(p.s.storage.types.elec) - GridToLoad = (sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - for ts in p.time_steps) - GridToBatt = (sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - for ts in p.time_steps) + Year1UtilityEnergy = p.hours_per_time_step * sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvGridPurchase"*_n)][s, ts, tier]) + for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) for s in 1:p.n_scenarios) + r["annual_energy_supplied_kwh"] = round(Year1UtilityEnergy, digits=2) + + if !isempty(p.s.storage.types.elec) + GridToLoad = [sum(p.scenario_probabilities[s] * (sum(value(m[Symbol("dvGridPurchase"*_n)][s, ts, tier]) for tier in 1:p.s.electric_tariff.n_energy_tiers) - sum(value(m[Symbol("dvGridToStorage"*_n)][s, b, ts]) for b in p.s.storage.types.elec)) for s in 1:p.n_scenarios) for ts in p.time_steps] + GridToBatt = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvGridToStorage"*_n)][s, b, ts]) for b in p.s.storage.types.elec) for s in 1:p.n_scenarios) for ts in p.time_steps] else - GridToLoad = (sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - for ts in p.time_steps) + GridToLoad = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvGridPurchase"*_n)][s, ts, tier]) for tier in 1:p.s.electric_tariff.n_energy_tiers) for s in 1:p.n_scenarios) for ts in p.time_steps] GridToBatt = zeros(length(p.time_steps)) end - r["electric_to_load_series_kw"] = round.(value.(GridToLoad), digits=3) - r["electric_to_storage_series_kw"] = round.(value.(GridToBatt), digits=3) + r["electric_to_load_series_kw"] = round.(GridToLoad, digits=3) + r["electric_to_storage_series_kw"] = round.(GridToBatt, digits=3) if _n=="" #only output emissions and RE results if not a multinode model r["lifecycle_emissions_tonnes_CO2"] = round(value(m[:Lifecycle_Emissions_Lbs_CO2_grid_net_if_selected]*TONNE_PER_LB), digits=2) @@ -93,20 +89,20 @@ function add_electric_utility_results(m::JuMP.AbstractModel, p::MPCInputs, d::Di r = Dict{String, Any}() Year1UtilityEnergy = p.hours_per_time_step * - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for ts in p.time_steps, + sum(m[Symbol("dvGridPurchase"*_n)][s, ts, tier] for s in 1:p.n_scenarios, ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) r["energy_supplied_kwh"] = round(value(Year1UtilityEnergy), digits=2) if p.s.storage.attr["ElectricStorage"].size_kwh > 0 GridToBatt = @expression(m, [ts in p.time_steps], - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) + sum(m[Symbol("dvGridToStorage"*_n)][1, b, ts] for b in p.s.storage.types.elec) ) r["to_battery_series_kw"] = round.(value.(GridToBatt), digits=3).data else GridToBatt = zeros(length(p.time_steps)) end GridToLoad = @expression(m, [ts in p.time_steps], - sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - + sum(m[Symbol("dvGridPurchase"*_n)][1, ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - GridToBatt[ts] ) r["to_load_series_kw"] = round.(value.(GridToLoad), digits=3).data diff --git a/src/results/existing_boiler.jl b/src/results/existing_boiler.jl index 13ed986c0..6929468aa 100644 --- a/src/results/existing_boiler.jl +++ b/src/results/existing_boiler.jl @@ -20,23 +20,23 @@ """ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r = Dict{String, Any}() - max_prod_kw = maximum(value.(sum(m[Symbol("dvHeatingProduction"*_n)]["ExistingBoiler",q,:] for q in p.heating_loads))) + max_prod_kw = maximum([sum(p.scenario_probabilities[s] * value(sum(m[Symbol("dvHeatingProduction"*_n)][s,"ExistingBoiler",q,ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) for ts in p.time_steps]) size_actual_mmbtu_per_hour = max_prod_kw / KWH_PER_MMBTU * p.s.existing_boiler.max_thermal_factor_on_peak_load r["size_mmbtu_per_hour"] = round(size_actual_mmbtu_per_hour, digits=3) r["fuel_consumption_series_mmbtu_per_hour"] = - round.(value.(m[:dvFuelUsage]["ExistingBoiler", ts] for ts in p.time_steps) ./ KWH_PER_MMBTU, digits=5) + round.([sum(p.scenario_probabilities[s] * value(m[:dvFuelUsage][s, "ExistingBoiler", ts]) for s in 1:p.n_scenarios) / KWH_PER_MMBTU for ts in p.time_steps], digits=5) r["annual_fuel_consumption_mmbtu"] = round(sum(r["fuel_consumption_series_mmbtu_per_hour"]), digits=5) r["thermal_production_series_mmbtu_per_hour"] = - round.(sum(value.(m[:dvHeatingProduction]["ExistingBoiler", q, ts] for ts in p.time_steps) for q in p.heating_loads) ./ KWH_PER_MMBTU, digits=5) + round.([sum(p.scenario_probabilities[s] * value(sum(m[:dvHeatingProduction][s, "ExistingBoiler", q, ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) / KWH_PER_MMBTU for ts in p.time_steps], digits=5) r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=5) if !isempty(p.s.storage.types.hot) @expression(m, BoilerToHotTESKW[ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ExistingBoiler",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ExistingBoiler",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, q in p.heating_loads) ) @expression(m, BoilerToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[:dvHeatToStorage][b,"ExistingBoiler",q,ts] for b in p.s.storage.types.hot) + sum(p.scenario_probabilities[s] * m[:dvHeatToStorage][s, b,"ExistingBoiler",q,ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot) ) else BoilerToHotTESKW = zeros(length(p.time_steps)) @@ -45,8 +45,8 @@ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(BoilerToHotTESKW / KWH_PER_MMBTU), digits=3) if !isempty(p.techs.steam_turbine) && p.s.existing_boiler.can_supply_steam_turbine - @expression(m, BoilerToSteamTurbineKW[ts in p.time_steps], sum(m[:dvThermalToSteamTurbine]["ExistingBoiler",q,ts] for q in p.heating_loads)) - @expression(m, BoilerToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], m[:dvThermalToSteamTurbine]["ExistingBoiler",q,ts]) + @expression(m, BoilerToSteamTurbineKW[ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s, "ExistingBoiler",q,ts] for s in 1:p.n_scenarios, q in p.heating_loads)) + @expression(m, BoilerToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], sum(p.scenario_probabilities[s] * m[:dvThermalToSteamTurbine][s, "ExistingBoiler",q,ts] for s in 1:p.n_scenarios)) else @expression(m, BoilerToSteamTurbineKW[ts in p.time_steps], 0.0) @expression(m, BoilerToSteamTurbineByQualityKW[q in p.heating_loads, ts in p.time_steps], 0.0) @@ -55,13 +55,13 @@ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::D BoilerToLoadKW = @expression(m, [ts in p.time_steps], - sum(value.(m[:dvHeatingProduction]["ExistingBoiler",q,ts] for q in p.heating_loads)) - BoilerToHotTESKW[ts] - BoilerToSteamTurbineKW[ts] + sum(p.scenario_probabilities[s] * value(m[:dvHeatingProduction][s, "ExistingBoiler",q,ts]) for s in 1:p.n_scenarios, q in p.heating_loads) - BoilerToHotTESKW[ts] - BoilerToSteamTurbineKW[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(BoilerToLoadKW ./ KWH_PER_MMBTU), digits=5) if "DomesticHotWater" in p.heating_loads && p.s.existing_boiler.can_serve_dhw @expression(m, BoilerToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["ExistingBoiler","DomesticHotWater",ts] - BoilerToHotTESByQualityKW["DomesticHotWater",ts] - BoilerToSteamTurbineByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ExistingBoiler","DomesticHotWater",ts] for s in 1:p.n_scenarios) - BoilerToHotTESByQualityKW["DomesticHotWater",ts] - BoilerToSteamTurbineByQualityKW["DomesticHotWater",ts] ) else @expression(m, BoilerToDHWKW[ts in p.time_steps], 0.0) @@ -70,7 +70,7 @@ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "SpaceHeating" in p.heating_loads && p.s.existing_boiler.can_serve_space_heating @expression(m, BoilerToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["ExistingBoiler","SpaceHeating",ts] - BoilerToHotTESByQualityKW["SpaceHeating",ts] - BoilerToSteamTurbineByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ExistingBoiler","SpaceHeating",ts] for s in 1:p.n_scenarios) - BoilerToHotTESByQualityKW["SpaceHeating",ts] - BoilerToSteamTurbineByQualityKW["SpaceHeating",ts] ) else @expression(m, BoilerToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -79,7 +79,7 @@ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "ProcessHeat" in p.heating_loads && p.s.existing_boiler.can_serve_process_heat @expression(m, BoilerToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["ExistingBoiler","ProcessHeat",ts] - BoilerToHotTESByQualityKW["ProcessHeat",ts] - BoilerToSteamTurbineByQualityKW["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s, "ExistingBoiler","ProcessHeat",ts] for s in 1:p.n_scenarios) - BoilerToHotTESByQualityKW["ProcessHeat",ts] - BoilerToSteamTurbineByQualityKW["ProcessHeat",ts] ) else @expression(m, BoilerToProcessHeatKW[ts in p.time_steps], 0.0) @@ -87,7 +87,7 @@ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r["thermal_to_process_heat_load_series_mmbtu_per_hour"] = round.(value.(BoilerToProcessHeatKW ./ KWH_PER_MMBTU), digits=5) m[:TotalExistingBoilerFuelCosts] = @expression(m, p.pwf_fuel["ExistingBoiler"] * - sum(m[:dvFuelUsage]["ExistingBoiler", ts] * p.fuel_cost_per_kwh["ExistingBoiler"][ts] for ts in p.time_steps) + sum(p.scenario_probabilities[s] * m[:dvFuelUsage][s, "ExistingBoiler", ts] * p.fuel_cost_per_kwh["ExistingBoiler"][ts] for s in 1:p.n_scenarios, ts in p.time_steps) ) r["lifecycle_fuel_cost_after_tax"] = round(value(m[:TotalExistingBoilerFuelCosts]) * (1 - p.s.financial.offtaker_tax_rate_fraction), digits=3) r["year_one_fuel_cost_before_tax"] = round(value(m[:TotalExistingBoilerFuelCosts]) / p.pwf_fuel["ExistingBoiler"], digits=3) diff --git a/src/results/existing_chiller.jl b/src/results/existing_chiller.jl index 5b73a833c..e16e265cf 100644 --- a/src/results/existing_chiller.jl +++ b/src/results/existing_chiller.jl @@ -14,30 +14,30 @@ function add_existing_chiller_results(m::JuMP.AbstractModel, p::REoptInputs, d:: r["size_ton"] = round(value(m[Symbol("dvSize"*_n)]["ExistingChiller"]) * p.s.existing_chiller.max_thermal_factor_on_peak_load / KWH_THERMAL_PER_TONHOUR, digits=3) @expression(m, ELECCHLtoTES[ts in p.time_steps], - sum(m[:dvProductionToStorage][b,"ExistingChiller",ts] for b in p.s.storage.types.cold) + sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s,b,"ExistingChiller",ts] for s in 1:p.n_scenarios, b in p.s.storage.types.cold) ) r["thermal_to_storage_series_ton"] = round.(value.(ELECCHLtoTES / KWH_THERMAL_PER_TONHOUR), digits=3) @expression(m, ELECCHLtoLoad[ts in p.time_steps], - sum(m[:dvCoolingProduction]["ExistingChiller", ts]) + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s,"ExistingChiller", ts] for s in 1:p.n_scenarios) - ELECCHLtoTES[ts] ) r["thermal_to_load_series_ton"] = round.(value.(ELECCHLtoLoad / KWH_THERMAL_PER_TONHOUR).data, digits=3) @expression(m, ELECCHLElecConsumptionSeries[ts in p.time_steps], - sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts]) + sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s,"ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts] for s in 1:p.n_scenarios) ) r["electric_consumption_series_kw"] = round.(value.(ELECCHLElecConsumptionSeries).data, digits=3) @expression(m, Year1ELECCHLElecConsumption, - p.hours_per_time_step * sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts] - for ts in p.time_steps) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s,"ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts] + for s in 1:p.n_scenarios, ts in p.time_steps) ) r["annual_electric_consumption_kwh"] = round(value(Year1ELECCHLElecConsumption), digits=3) @expression(m, Year1ELECCHLThermalProd, - p.hours_per_time_step * sum(m[:dvCoolingProduction]["ExistingChiller", ts] - for ts in p.time_steps) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[:dvCoolingProduction][s,"ExistingChiller", ts] + for s in 1:p.n_scenarios, ts in p.time_steps) ) r["annual_thermal_production_tonhour"] = round(value(Year1ELECCHLThermalProd / KWH_THERMAL_PER_TONHOUR), digits=3) diff --git a/src/results/generator.jl b/src/results/generator.jl index cc6506a40..a1b81a478 100644 --- a/src/results/generator.jl +++ b/src/results/generator.jl @@ -29,8 +29,8 @@ function add_generator_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _ GenPerUnitSizeOMCosts = @expression(m, p.third_party_factor * p.pwf_om * sum(m[:dvSize][t] * p.om_cost_per_kw[t] for t in p.techs.gen)) GenPerUnitProdOMCosts = @expression(m, p.third_party_factor * p.pwf_om * p.hours_per_time_step * - sum(m[:dvRatedProduction][t, ts] * p.production_factor[t, ts] * p.s.generator.om_cost_per_kwh - for t in p.techs.gen, ts in p.time_steps) + sum(p.scenario_probabilities[s] * m[:dvRatedProduction][s, t, ts] * p.production_factor[t, ts] * p.s.generator.om_cost_per_kwh + for s in 1:p.n_scenarios, t in p.techs.gen, ts in p.time_steps) ) r["size_kw"] = round(value(sum(m[:dvSize][t] for t in p.techs.gen)), digits=2) r["lifecycle_fixed_om_cost_after_tax"] = round(value(GenPerUnitSizeOMCosts) * (1 - p.s.financial.owner_tax_rate_fraction), digits=0) @@ -42,34 +42,31 @@ function add_generator_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _ r["year_one_fixed_om_cost_before_tax"] = round(value(GenPerUnitSizeOMCosts) / (p.pwf_om * p.third_party_factor), digits=0) if !isempty(p.s.storage.types.elec) - generatorToBatt = @expression(m, [ts in p.time_steps], - sum(m[:dvProductionToStorage][b, t, ts] for b in p.s.storage.types.elec, t in p.techs.gen)) + generatorToBatt = [sum(p.scenario_probabilities[s] * sum(value(m[:dvProductionToStorage][s, b, t, ts]) for b in p.s.storage.types.elec, t in p.techs.gen) for s in 1:p.n_scenarios) for ts in p.time_steps] else generatorToBatt = zeros(length(p.time_steps)) end - r["electric_to_storage_series_kw"] = round.(value.(generatorToBatt), digits=3) - - generatorToGrid = @expression(m, [ts in p.time_steps], - sum(m[:dvProductionToGrid][t, u, ts] for t in p.techs.gen, u in p.export_bins_by_tech[t]) - ) - r["electric_to_grid_series_kw"] = round.(value.(generatorToGrid), digits=3) + r["electric_to_storage_series_kw"] = round.(generatorToBatt, digits=3) + + generatorToGrid = zeros(length(p.time_steps)) + if !isempty(p.s.electric_tariff.export_bins) + for t in p.techs.gen + for u in p.export_bins_by_tech[t] + generatorToGrid .+= [p.scenario_probabilities[s] * value(m[:dvProductionToGrid][s, t, u, ts]) for s in 1:p.n_scenarios for ts in p.time_steps] + end + end + end + r["electric_to_grid_series_kw"] = round.(generatorToGrid, digits=3) - generatorToLoad = @expression(m, [ts in p.time_steps], - sum(m[:dvRatedProduction][t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] - for t in p.techs.gen) - - generatorToBatt[ts] - generatorToGrid[ts] - ) - r["electric_to_load_series_kw"] = round.(value.(generatorToLoad), digits=3) + generatorToLoad = [sum(p.scenario_probabilities[s] * sum(value(m[:dvRatedProduction][s, t, ts]) * p.production_factor[t, ts] * p.levelization_factor[t] for t in p.techs.gen) for s in 1:p.n_scenarios) - generatorToBatt[ts] - generatorToGrid[ts] for ts in p.time_steps] + r["electric_to_load_series_kw"] = round.(generatorToLoad, digits=3) - GeneratorFuelUsed = @expression(m, sum(m[:dvFuelUsage][t, ts] for t in p.techs.gen, ts in p.time_steps) / p.s.generator.fuel_higher_heating_value_kwh_per_gal) - r["annual_fuel_consumption_gal"] = round(value(GeneratorFuelUsed), digits=2) + GeneratorFuelUsed = sum(p.scenario_probabilities[s] * sum(value(m[:dvFuelUsage][s, t, ts]) for t in p.techs.gen, ts in p.time_steps) for s in 1:p.n_scenarios) / p.s.generator.fuel_higher_heating_value_kwh_per_gal + r["annual_fuel_consumption_gal"] = round(GeneratorFuelUsed, digits=2) - AverageGenProd = @expression(m, - p.hours_per_time_step * sum(m[:dvRatedProduction][t,ts] * p.production_factor[t, ts] * - p.levelization_factor[t] - for t in p.techs.gen, ts in p.time_steps) - ) - r["annual_energy_produced_kwh"] = round(value(AverageGenProd), digits=0) + AverageGenProd = p.hours_per_time_step * sum(p.scenario_probabilities[s] * sum(value(m[:dvRatedProduction][s, t,ts]) * p.production_factor[t, ts] * + p.levelization_factor[t] for t in p.techs.gen, ts in p.time_steps) for s in 1:p.n_scenarios) + r["annual_energy_produced_kwh"] = round(AverageGenProd, digits=0) d["Generator"] = r nothing @@ -93,29 +90,29 @@ function add_generator_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict; _n= if p.s.storage.attr["ElectricStorage"].size_kw > 0 generatorToBatt = @expression(m, [ts in p.time_steps], - sum(m[:dvProductionToStorage][b, t, ts] for b in p.s.storage.types.elec, t in p.techs.gen)) + sum(m[:dvProductionToStorage][1, b, t, ts] for b in p.s.storage.types.elec, t in p.techs.gen)) r["to_battery_series_kw"] = round.(value.(generatorToBatt), digits=3).data else generatorToBatt = zeros(length(p.time_steps)) end generatorToGrid = @expression(m, [ts in p.time_steps], - sum(m[:dvProductionToGrid][t, u, ts] for t in p.techs.gen, u in p.export_bins_by_tech[t]) + sum(m[:dvProductionToGrid][1, t, u, ts] for t in p.techs.gen, u in p.export_bins_by_tech[t]) ) r["to_grid_series_kw"] = round.(value.(generatorToGrid), digits=3).data generatorToLoad = @expression(m, [ts in p.time_steps], - sum(m[:dvRatedProduction][t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] + sum(m[:dvRatedProduction][1, t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] for t in p.techs.gen) - generatorToBatt[ts] - generatorToGrid[ts] ) r["to_load_series_kw"] = round.(value.(generatorToLoad), digits=3).data - GeneratorFuelUsed = @expression(m, sum(m[:dvFuelUsage][t, ts] for t in p.techs.gen, ts in p.time_steps) / p.s.generator.fuel_higher_heating_value_kwh_per_gal) + GeneratorFuelUsed = @expression(m, sum(m[:dvFuelUsage][1, t, ts] for t in p.techs.gen, ts in p.time_steps) / p.s.generator.fuel_higher_heating_value_kwh_per_gal) r["annual_fuel_consumption_gal"] = round(value(GeneratorFuelUsed), digits=2) Year1GenProd = @expression(m, - p.hours_per_time_step * sum(m[:dvRatedProduction][t,ts] * p.production_factor[t, ts] + p.hours_per_time_step * sum(m[:dvRatedProduction][1, t, ts] * p.production_factor[t, ts] for t in p.techs.gen, ts in p.time_steps) ) r["energy_produced_kwh"] = round(value(Year1GenProd), digits=0) diff --git a/src/results/pv.jl b/src/results/pv.jl index b76996cf4..ac7df082e 100644 --- a/src/results/pv.jl +++ b/src/results/pv.jl @@ -53,33 +53,32 @@ function add_pv_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") # NOTE: must use anonymous expressions in this loop to overwrite values for cases with multiple PV if !isempty(p.s.storage.types.elec) - PVtoBatt = (sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) for ts in p.time_steps) + PVtoBatt = (sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvProductionToStorage"*_n)][s, b, t, ts]) for b in p.s.storage.types.elec) for s in 1:p.n_scenarios) for ts in p.time_steps) else PVtoBatt = repeat([0], length(p.time_steps)) end - r["electric_to_storage_series_kw"] = round.(value.(PVtoBatt), digits=3) + r["electric_to_storage_series_kw"] = round.(collect(PVtoBatt), digits=3) r["electric_to_grid_series_kw"] = zeros(size(r["electric_to_storage_series_kw"])) r["annual_energy_exported_kwh"] = 0.0 if !isempty(p.s.electric_tariff.export_bins) - PVtoGrid = @expression(m, [ts in p.time_steps], - sum(m[:dvProductionToGrid][t, u, ts] for u in p.export_bins_by_tech[t])) - r["electric_to_grid_series_kw"] = round.(value.(PVtoGrid), digits=3).data + PVtoGrid = [sum(p.scenario_probabilities[s] * sum(value(m[:dvProductionToGrid][s, t, u, ts]) for u in p.export_bins_by_tech[t]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["electric_to_grid_series_kw"] = round.(PVtoGrid, digits=3) r["annual_energy_exported_kwh"] = round( sum(r["electric_to_grid_series_kw"]) * p.hours_per_time_step, digits=0) end - PVtoCUR = (m[Symbol("dvCurtail"*_n)][t, ts] for ts in p.time_steps) - r["electric_curtailed_series_kw"] = round.(value.(PVtoCUR), digits=3) - PVtoLoad = (m[Symbol("dvRatedProduction"*_n)][t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] + PVtoCUR = (sum(p.scenario_probabilities[s] * value(m[Symbol("dvCurtail"*_n)][s, t, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps) + r["electric_curtailed_series_kw"] = round.(collect(PVtoCUR), digits=3) + PVtoLoad = (sum(p.scenario_probabilities[s] * value(m[Symbol("dvRatedProduction"*_n)][s, t, ts]) for s in 1:p.n_scenarios) * p.production_factor[t, ts] * p.levelization_factor[t] - r["electric_curtailed_series_kw"][ts] - r["electric_to_grid_series_kw"][ts] - r["electric_to_storage_series_kw"][ts] for ts in p.time_steps ) - r["electric_to_load_series_kw"] = round.(value.(PVtoLoad), digits=3) - Year1PvProd = (sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] for ts in p.time_steps) * p.hours_per_time_step) - r["year_one_energy_produced_kwh"] = round(value(Year1PvProd), digits=0) + r["electric_to_load_series_kw"] = round.(collect(PVtoLoad), digits=3) + Year1PvProd = sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) * p.production_factor[t, ts] for ts in p.time_steps) for s in 1:p.n_scenarios) * p.hours_per_time_step + r["year_one_energy_produced_kwh"] = round(Year1PvProd, digits=0) r["annual_energy_produced_kwh"] = round(r["year_one_energy_produced_kwh"] * p.levelization_factor[t], digits=2) r["om_cost_per_kw"] = p.om_cost_per_kw[t] PVPerUnitSizeOMCosts = p.third_party_factor * p.om_cost_per_kw[t] * p.pwf_om * m[Symbol("dvSize"*_n)][t] @@ -104,7 +103,7 @@ function add_pv_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict; _n="") # NOTE: must use anonymous expressions in this loop to overwrite values for cases with multiple PV if !isempty(p.s.storage.types.elec) - PVtoBatt = (sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) for ts in p.time_steps) + PVtoBatt = (sum(m[Symbol("dvProductionToStorage"*_n)][1, b, t, ts] for b in p.s.storage.types.elec) for ts in p.time_steps) PVtoBatt = round.(value.(PVtoBatt), digits=3) else PVtoBatt = zeros(length(p.time_steps)) @@ -114,19 +113,19 @@ function add_pv_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict; _n="") r["to_grid_series_kw"] = zeros(length(p.time_steps)) if !isempty(p.s.electric_tariff.export_bins) PVtoGrid = @expression(m, [ts in p.time_steps], - sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t])) + sum(m[Symbol("dvProductionToGrid"*_n)][1, t, u, ts] for u in p.export_bins_by_tech[t])) r["to_grid_series_kw"] = round.(value.(PVtoGrid), digits=3).data end - PVtoCUR = (m[Symbol("dvCurtail"*_n)][t, ts] for ts in p.time_steps) + PVtoCUR = (m[Symbol("dvCurtail"*_n)][1, t, ts] for ts in p.time_steps) r["curtailed_production_series_kw"] = round.(value.(PVtoCUR), digits=3) - PVtoLoad = (m[Symbol("dvRatedProduction"*_n)][t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] + PVtoLoad = (m[Symbol("dvRatedProduction"*_n)][1, t, ts] * p.production_factor[t, ts] * p.levelization_factor[t] - r["curtailed_production_series_kw"][ts] - r["to_grid_series_kw"][ts] - PVtoBatt[ts] for ts in p.time_steps ) r["to_load_series_kw"] = round.(value.(PVtoLoad), digits=3) - Year1PvProd = (sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] for ts in p.time_steps) * p.hours_per_time_step) + Year1PvProd = (sum(m[Symbol("dvRatedProduction"*_n)][1, t, ts] * p.production_factor[t, ts] for ts in p.time_steps) * p.hours_per_time_step) r["energy_produced_kwh"] = round(value(Year1PvProd), digits=0) d[t] = r end diff --git a/src/results/site.jl b/src/results/site.jl index cf9c21343..8bd151a38 100644 --- a/src/results/site.jl +++ b/src/results/site.jl @@ -114,15 +114,15 @@ function add_re_tot_calcs(m::JuMP.AbstractModel, p::REoptInputs) # end #To account for hot storage losses and the RE contributions from fuel-fired sources when calculating end-use load, these expressions are used. - m[:AnnualHeatContributionToStorage] = @expression(m, sum(m[:dvProductionToStorage][b,t,ts] for t in union(p.techs.heating, p.techs.chp), b in p.s.storage.types.hot, ts in p.time_steps)) - m[:AnnualREFBToHotStoragekWh] = @expression(m, sum(m[:dvProductionToStorage][b,t,ts]*p.tech_renewable_energy_fraction[t] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), b in p.s.storage.types.hot, ts in p.time_steps)) - m[:AnnualFBToHotStoragekWh] = @expression(m, sum(m[:dvProductionToStorage][b,t,ts] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), b in p.s.storage.types.hot, ts in p.time_steps)) + m[:AnnualHeatContributionToStorage] = @expression(m, sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s,b,t,ts] for s in 1:p.n_scenarios, t in union(p.techs.heating, p.techs.chp), b in p.s.storage.types.hot, ts in p.time_steps)) + m[:AnnualREFBToHotStoragekWh] = @expression(m, sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s,b,t,ts]*p.tech_renewable_energy_fraction[t] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), b in p.s.storage.types.hot, ts in p.time_steps)) + m[:AnnualFBToHotStoragekWh] = @expression(m, sum(p.scenario_probabilities[s] * m[:dvProductionToStorage][s,b,t,ts] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), b in p.s.storage.types.hot, ts in p.time_steps)) if value(m[:AnnualFBToHotStoragekWh]) > 0.0 m[:FBStorageDeliveryREFraction] = @expression(m, m[:AnnualREFBToHotStoragekWh] / m[:AnnualFBToHotStoragekWh]) else m[:FBStorageDeliveryREFraction] = @expression(m, 0.0) end - m[:AnnualHotStorageLosses] = @expression(m, m[:AnnualFBToHotStoragekWh] - sum(m[:dvDischargeFromStorage][b, ts] for b in p.s.storage.types.hot, ts in p.time_steps)) + m[:AnnualHotStorageLosses] = @expression(m, m[:AnnualFBToHotStoragekWh] - sum(p.scenario_probabilities[s] * m[:dvDischargeFromStorage][s, b, ts] for s in 1:p.n_scenarios, b in p.s.storage.types.hot, ts in p.time_steps)) if value(m[:AnnualHeatContributionToStorage]) > 0.0 m[:FBToHotStorageFraction] = @expression(m, m[:AnnualFBToHotStoragekWh] / m[:AnnualHeatContributionToStorage]) else @@ -130,8 +130,8 @@ function add_re_tot_calcs(m::JuMP.AbstractModel, p::REoptInputs) end # End-use consumed heating load from renewable, fuel-fired sources (electrified heat is addressed in the renewable electricity calculation) m[:AnnualREHeatkWh] = @expression(m,p.hours_per_time_step*( - sum(m[:dvHeatingProduction][t,q,ts] * p.tech_renewable_energy_fraction[t] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #total RE end-use heat generation from fuel sources - - sum(m[:dvProductionToWaste][t,q,ts]* p.tech_renewable_energy_fraction[t] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #minus waste heat + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,t,q,ts] * p.tech_renewable_energy_fraction[t] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #total RE end-use heat generation from fuel sources + - sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s,t,q,ts]* p.tech_renewable_energy_fraction[t] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #minus waste heat - m[:FBStorageDeliveryREFraction] * m[:FBToHotStorageFraction] * m[:AnnualHotStorageLosses] # RE weight times hot storage loss attributable to FB techs ) # - AnnualRESteamToSteamTurbine # minus RE steam feeding steam turbine, adjusted by p.hours_per_time_step @@ -140,8 +140,8 @@ function add_re_tot_calcs(m::JuMP.AbstractModel, p::REoptInputs) # End-use consumed heating load from fuel-fired sources (electrified heat is addressed in the renewable electricity calculation) m[:AnnualHeatkWh] = @expression(m,p.hours_per_time_step*( - sum(m[:dvHeatingProduction][t,q,ts] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #total end-use heat generation from fuel sources - - sum(m[:dvProductionToWaste][t,q,ts] for t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #minus waste heat + sum(p.scenario_probabilities[s] * m[:dvHeatingProduction][s,t,q,ts] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #total end-use heat generation from fuel sources + - sum(p.scenario_probabilities[s] * m[:dvProductionToWaste][s,t,q,ts] for s in 1:p.n_scenarios, t in intersect(p.techs.fuel_burning, union(p.techs.heating, p.techs.chp)), q in p.heating_loads, ts in p.time_steps) #minus waste heat - m[:FBToHotStorageFraction] * m[:AnnualHotStorageLosses] # hot storage loss attributable to FB techs ) # - AnnualSteamToSteamTurbine # minus steam going to SteamTurbine; already adjusted by p.hours_per_time_step diff --git a/src/results/steam_turbine.jl b/src/results/steam_turbine.jl index aa4973630..0f3d8e4b8 100644 --- a/src/results/steam_turbine.jl +++ b/src/results/steam_turbine.jl @@ -26,52 +26,47 @@ function add_steam_turbine_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dic r["size_kw"] = round(value(sum(m[Symbol("dvSize"*_n)][t] for t in p.techs.steam_turbine)), digits=3) @expression(m, Year1SteamTurbineThermalConsumptionKWH, - p.hours_per_time_step * sum(m[Symbol("dvThermalToSteamTurbine"*_n)][tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[Symbol("dvThermalToSteamTurbine"*_n)][s,tst,q,ts] for s in 1:p.n_scenarios, tst in p.techs.can_supply_steam_turbine, q in p.heating_loads, ts in p.time_steps)) r["annual_thermal_consumption_mmbtu"] = round(value(Year1SteamTurbineThermalConsumptionKWH) / KWH_PER_MMBTU, digits=5) - @expression(m, Year1SteamTurbineElecProd, - p.hours_per_time_step * sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] - for t in p.techs.steam_turbine, ts in p.time_steps)) - r["annual_electric_production_kwh"] = round(value(Year1SteamTurbineElecProd), digits=3) + Year1SteamTurbineElecProd = p.hours_per_time_step * sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) * p.production_factor[t, ts] + for t in p.techs.steam_turbine, ts in p.time_steps) for s in 1:p.n_scenarios) + r["annual_electric_production_kwh"] = round(Year1SteamTurbineElecProd, digits=3) @expression(m, Year1SteamTurbineThermalProdKWH, - p.hours_per_time_step * sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads, t in p.techs.steam_turbine, ts in p.time_steps)) + p.hours_per_time_step * sum(p.scenario_probabilities[s] * m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.steam_turbine, ts in p.time_steps)) r["annual_thermal_production_mmbtu"] = round(value(Year1SteamTurbineThermalProdKWH) / KWH_PER_MMBTU, digits=5) @expression(m, SteamTurbineThermalConsumptionKW[ts in p.time_steps], - sum(m[Symbol("dvThermalToSteamTurbine"*_n)][tst,q,ts] for tst in p.techs.can_supply_steam_turbine, q in p.heating_loads)) + sum(p.scenario_probabilities[s] * m[Symbol("dvThermalToSteamTurbine"*_n)][s,tst,q,ts] for s in 1:p.n_scenarios, tst in p.techs.can_supply_steam_turbine, q in p.heating_loads)) r["thermal_consumption_series_mmbtu_per_hour"] = round.(value.(SteamTurbineThermalConsumptionKW) ./ KWH_PER_MMBTU, digits=5) - @expression(m, SteamTurbineElecProdTotal[ts in p.time_steps], - sum(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] for t in p.techs.steam_turbine)) - r["electric_production_series_kw"] = round.(value.(SteamTurbineElecProdTotal), digits=3) + SteamTurbineElecProdTotal = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) * p.production_factor[t, ts] for t in p.techs.steam_turbine) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["electric_production_series_kw"] = round.(SteamTurbineElecProdTotal, digits=3) if !isempty(p.s.electric_tariff.export_bins) - @expression(m, SteamTurbinetoGrid[ts in p.time_steps], - sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for t in p.techs.steam_turbine, u in p.export_bins_by_tech[t])) + SteamTurbinetoGrid = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvProductionToGrid"*_n)][s, t, u, ts]) for t in p.techs.steam_turbine, u in p.export_bins_by_tech[t]) for s in 1:p.n_scenarios) for ts in p.time_steps] else SteamTurbinetoGrid = zeros(length(p.time_steps)) end - r["electric_to_grid_series_kw"] = round.(value.(SteamTurbinetoGrid), digits=3) + r["electric_to_grid_series_kw"] = round.(SteamTurbinetoGrid, digits=3) if !isempty(p.s.storage.types.elec) - @expression(m, SteamTurbinetoBatt[ts in p.time_steps], - sum(m[Symbol("dvProductionToStorage"*_n)]["ElectricStorage",t,ts] for t in p.techs.steam_turbine)) + SteamTurbinetoBatt = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvProductionToStorage"*_n)][s, "ElectricStorage",t,ts]) for t in p.techs.steam_turbine) for s in 1:p.n_scenarios) for ts in p.time_steps] else SteamTurbinetoBatt = zeros(length(p.time_steps)) end - r["electric_to_storage_series_kw"] = round.(value.(SteamTurbinetoBatt), digits=3) - @expression(m, SteamTurbinetoLoad[ts in p.time_steps], - sum(m[Symbol("dvRatedProduction"*_n)][t, ts] * p.production_factor[t, ts] - for t in p.techs.steam_turbine) - SteamTurbinetoBatt[ts] - SteamTurbinetoGrid[ts]) - r["electric_to_load_series_kw"] = round.(value.(SteamTurbinetoLoad), digits=3) + r["electric_to_storage_series_kw"] = round.(SteamTurbinetoBatt, digits=3) + SteamTurbinetoLoad = [sum(p.scenario_probabilities[s] * sum(value(m[Symbol("dvRatedProduction"*_n)][s, t, ts]) * p.production_factor[t, ts] + for t in p.techs.steam_turbine) for s in 1:p.n_scenarios) - SteamTurbinetoBatt[ts] - SteamTurbinetoGrid[ts] for ts in p.time_steps] + r["electric_to_load_series_kw"] = round.(SteamTurbinetoLoad, digits=3) if !isempty(p.s.storage.types.hot) if "HotThermalStorage" in p.s.storage.types.hot @expression(m, SteamTurbinetoHotTESKW[ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)]["HotThermalStorage",t,q,ts] for q in p.heating_loads, t in p.techs.steam_turbine)) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s,"HotThermalStorage",t,q,ts] for s in 1:p.n_scenarios, q in p.heating_loads, t in p.techs.steam_turbine)) @expression(m, SteamTurbineToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)]["HotThermalStorage",t,q,ts] for t in p.techs.steam_turbine)) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s,"HotThermalStorage",t,q,ts] for s in 1:p.n_scenarios, t in p.techs.steam_turbine)) else @expression(m, SteamTurbinetoHotTESKW[ts in p.time_steps], 0.0) @expression(m, SteamTurbineToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], 0.0) end if "HighTempThermalStorage" in p.s.storage.types.hot @expression(m, SteamTurbineToHotSensibleTESKW[ts in p.time_steps], - sum(m[Symbol("dvHeatToStorage"*_n)]["HighTempThermalStorage",t,q,ts] for t in p.techs.steam_turbine, q in p.heating_loads) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatToStorage"*_n)][s,"HighTempThermalStorage",t,q,ts] for s in 1:p.n_scenarios, t in p.techs.steam_turbine, q in p.heating_loads) ) else @expression(m, SteamTurbineToHotSensibleTESKW[ts in p.time_steps], 0.0) @@ -85,12 +80,12 @@ function add_steam_turbine_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dic r["thermal_to_high_temp_thermal_storage_series_mmbtu_per_hour"] = round.(value.(m[:SteamTurbineToHotSensibleTESKW]) ./ KWH_PER_MMBTU, digits=5) @expression(m, SteamTurbineThermalToLoadKW[ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for t in p.techs.steam_turbine, q in p.heating_loads) - SteamTurbinetoHotTESKW[ts]) + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatingProduction"*_n)][s,t,q,ts] for s in 1:p.n_scenarios, t in p.techs.steam_turbine, q in p.heating_loads) - SteamTurbinetoHotTESKW[ts]) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(SteamTurbineThermalToLoadKW) ./ KWH_PER_MMBTU, digits=5) if "DomesticHotWater" in p.heating_loads && p.s.steam_turbine.can_serve_dhw @expression(m, SteamTurbineToDHWKW[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["SteamTurbine","DomesticHotWater",ts] - SteamTurbineToHotTESByQualityKW["DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatingProduction"*_n)][s,"SteamTurbine","DomesticHotWater",ts] for s in 1:p.n_scenarios) - SteamTurbineToHotTESByQualityKW["DomesticHotWater",ts] ) else @expression(m, SteamTurbineToDHWKW[ts in p.time_steps], 0.0) @@ -99,7 +94,7 @@ function add_steam_turbine_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dic if "SpaceHeating" in p.heating_loads && p.s.steam_turbine.can_serve_space_heating @expression(m, SteamTurbineToSpaceHeatingKW[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["SteamTurbine","SpaceHeating",ts] - SteamTurbineToHotTESByQualityKW["SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatingProduction"*_n)][s,"SteamTurbine","SpaceHeating",ts] for s in 1:p.n_scenarios) - SteamTurbineToHotTESByQualityKW["SpaceHeating",ts] ) else @expression(m, SteamTurbineToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -108,7 +103,7 @@ function add_steam_turbine_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dic if "ProcessHeat" in p.heating_loads && p.s.steam_turbine.can_serve_process_heat @expression(m, SteamTurbineToProcessHeatKW[ts in p.time_steps], - m[Symbol("dvHeatingProduction"*_n)]["SteamTurbine","ProcessHeat",ts] - SteamTurbineToHotTESByQualityKW["ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatingProduction"*_n)][s,"SteamTurbine","ProcessHeat",ts] for s in 1:p.n_scenarios) - SteamTurbineToHotTESByQualityKW["ProcessHeat",ts] ) else @expression(m, SteamTurbineToProcessHeatKW[ts in p.time_steps], 0.0) diff --git a/src/results/thermal_storage.jl b/src/results/thermal_storage.jl index 29b5afd81..a2e848f1c 100644 --- a/src/results/thermal_storage.jl +++ b/src/results/thermal_storage.jl @@ -23,23 +23,23 @@ function add_hot_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict, r["size_gal"] = round(size_kwh / kwh_per_gal, digits=0) if size_kwh != 0 - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) - r["soc_series_fraction"] = round.(value.(soc) ./ size_kwh, digits=3) + soc = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvStoredEnergy"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["soc_series_fraction"] = round.(soc ./ size_kwh, digits=3) - discharge = (sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for q in p.heating_loads) for ts in p.time_steps) + discharge = [sum(p.scenario_probabilities[s] * value(sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) for ts in p.time_steps] if p.s.storage.attr[b].can_supply_steam_turbine && ("SteamTurbine" in p.techs.all) - storage_to_turbine = (sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] for q in p.heating_loads) for ts in p.time_steps) - r["storage_to_turbine_series_mmbtu_per_hour"] = round.(value.(storage_to_turbine) / KWH_PER_MMBTU, digits=7) - r["storage_to_load_series_mmbtu_per_hour"] = round.(value.(discharge .- storage_to_turbine) / KWH_PER_MMBTU, digits=7) + storage_to_turbine = [sum(p.scenario_probabilities[s] * value(sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["storage_to_turbine_series_mmbtu_per_hour"] = round.(storage_to_turbine / KWH_PER_MMBTU, digits=7) + r["storage_to_load_series_mmbtu_per_hour"] = round.((discharge .- storage_to_turbine) / KWH_PER_MMBTU, digits=7) else - r["storage_to_load_series_mmbtu_per_hour"] = round.(value.(discharge) / KWH_PER_MMBTU, digits=7) + r["storage_to_load_series_mmbtu_per_hour"] = round.(discharge / KWH_PER_MMBTU, digits=7) r["storage_to_turbine_series_mmbtu_per_hour"] = zeros(length(p.time_steps)) end if "SpaceHeating" in p.heating_loads && p.s.storage.attr[b].can_serve_space_heating @expression(m, HotTESToSpaceHeatingKW[ts in p.time_steps], - m[Symbol("dvHeatFromStorage"*_n)][b,"SpaceHeating",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatFromStorage"*_n)][s,b,"SpaceHeating",ts] for s in 1:p.n_scenarios) ) else @expression(m, HotTESToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -48,7 +48,7 @@ function add_hot_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict, if "DomesticHotWater" in p.heating_loads && p.s.storage.attr[b].can_serve_dhw @expression(m, HotTESToDHWKW[ts in p.time_steps], - m[Symbol("dvHeatFromStorage"*_n)][b,"DomesticHotWater",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatFromStorage"*_n)][s,b,"DomesticHotWater",ts] for s in 1:p.n_scenarios) ) else @expression(m, HotTESToDHWKW[ts in p.time_steps], 0.0) @@ -57,7 +57,7 @@ function add_hot_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict, if "ProcessHeat" in p.heating_loads && p.s.storage.attr[b].can_serve_process_heat @expression(m, HotTESToProcessHeatKW[ts in p.time_steps], - m[Symbol("dvHeatFromStorage"*_n)][b,"ProcessHeat",ts] + sum(p.scenario_probabilities[s] * m[Symbol("dvHeatFromStorage"*_n)][s,b,"ProcessHeat",ts] for s in 1:p.n_scenarios) ) else @expression(m, HotTESToProcessHeatKW[ts in p.time_steps], 0.0) @@ -84,7 +84,7 @@ function add_hot_storage_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict, b =# r = Dict{String, Any}() - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) + soc = (m[Symbol("dvStoredEnergy"*_n)][1, b, ts] for ts in p.time_steps) r["soc_series_fraction"] = round.(value.(soc) ./ p.s.storage.attr[b].size_kwh, digits=3) d[b] = r @@ -111,11 +111,11 @@ function add_cold_storage_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict r["size_gal"] = round(size_kwh / kwh_per_gal, digits=0) if size_kwh != 0 - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) - r["soc_series_fraction"] = round.(value.(soc) ./ size_kwh, digits=3) + soc = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvStoredEnergy"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["soc_series_fraction"] = round.(soc ./ size_kwh, digits=3) - discharge = (m[Symbol("dvDischargeFromStorage"*_n)][b, ts] for ts in p.time_steps) - r["storage_to_load_series_ton"] = round.(value.(discharge) / KWH_THERMAL_PER_TONHOUR, digits=7) + discharge = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvDischargeFromStorage"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["storage_to_load_series_ton"] = round.(discharge / KWH_THERMAL_PER_TONHOUR, digits=7) else r["soc_series_fraction"] = [] r["storage_to_load_series_ton"] = [] @@ -136,7 +136,7 @@ function add_cold_storage_results(m::JuMP.AbstractModel, p::MPCInputs, d::Dict, =# r = Dict{String, Any}() - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) + soc = (m[Symbol("dvStoredEnergy"*_n)][1, b, ts] for ts in p.time_steps) r["soc_series_fraction"] = round.(value.(soc) ./ p.s.storage.attr[b].size_kwh, digits=3) d[b] = r @@ -163,20 +163,20 @@ function add_high_temp_thermal_storage_results(m::JuMP.AbstractModel, p::REoptIn r["size_kwh"] = size_kwh if size_kwh != 0 - soc = (m[Symbol("dvStoredEnergy"*_n)][b, ts] for ts in p.time_steps) - r["soc_series_fraction"] = round.(value.(soc) ./ size_kwh, digits=3) + soc = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvStoredEnergy"*_n)][s, b, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["soc_series_fraction"] = round.(soc ./ size_kwh, digits=3) - discharge = (sum(m[Symbol("dvHeatFromStorage"*_n)][b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) for ts in p.time_steps) + discharge = [sum(p.scenario_probabilities[s] * value(sum(m[Symbol("dvHeatFromStorage"*_n)][s,b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads)) for s in 1:p.n_scenarios) for ts in p.time_steps] # TODO: add something to track heat to steam turbine? # discharge = (sum(m[Symbol("dvThermalToSteamTurbine"*_n)][b,q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) for ts in p.time_steps) # r["storage_to_load_series_mmbtu_per_hour"] = round.(value.(discharge) / KWH_PER_MMBTU, digits=7) if p.s.storage.attr[b].can_supply_steam_turbine && ("SteamTurbine" in p.techs.all) - storage_to_turbine = (sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][b,q,ts] for q in p.heating_loads) for ts in p.time_steps) - r["storage_to_turbine_series_mmbtu_per_hour"] = round.(value.(storage_to_turbine) / KWH_PER_MMBTU, digits=7) - r["storage_to_load_series_mmbtu_per_hour"] = round.(value.(discharge .- storage_to_turbine) / KWH_PER_MMBTU, digits=7) + storage_to_turbine = [sum(p.scenario_probabilities[s] * value(sum(m[Symbol("dvHeatFromStorageToTurbine"*_n)][s,b,q,ts] for q in p.heating_loads)) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["storage_to_turbine_series_mmbtu_per_hour"] = round.(storage_to_turbine / KWH_PER_MMBTU, digits=7) + r["storage_to_load_series_mmbtu_per_hour"] = round.((discharge .- storage_to_turbine) / KWH_PER_MMBTU, digits=7) else - r["storage_to_load_series_mmbtu_per_hour"] = round.(value.(discharge) / KWH_PER_MMBTU, digits=7) + r["storage_to_load_series_mmbtu_per_hour"] = round.(discharge / KWH_PER_MMBTU, digits=7) r["storage_to_turbine_series_mmbtu_per_hour"] = zeros(length(p.time_steps)) end else diff --git a/src/results/wind.jl b/src/results/wind.jl index 8c976279c..999de68c6 100644 --- a/src/results/wind.jl +++ b/src/results/wind.jl @@ -31,37 +31,37 @@ function add_wind_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r["year_one_om_cost_before_tax"] = round(value(per_unit_size_om) / (p.pwf_om * p.third_party_factor), digits=0) if !isempty(p.s.storage.types.elec) - WindToStorage = (sum(m[:dvProductionToStorage][b, t, ts] for b in p.s.storage.types.elec) for ts in p.time_steps) + WindToStorage = [sum(p.scenario_probabilities[s] * sum(value(m[:dvProductionToStorage][s, b, t, ts]) for b in p.s.storage.types.elec) for s in 1:p.n_scenarios) for ts in p.time_steps] else WindToStorage = zeros(length(p.time_steps)) end - r["electric_to_storage_series_kw"] = round.(value.(WindToStorage), digits=3) + r["electric_to_storage_series_kw"] = round.(WindToStorage, digits=3) r["annual_energy_exported_kwh"] = 0.0 if !isempty(p.s.electric_tariff.export_bins) - WindToGrid = (sum(m[:dvProductionToGrid][t, u, ts] for u in p.export_bins_by_tech[t]) for ts in p.time_steps) - r["electric_to_grid_series_kw"] = round.(value.(WindToGrid), digits=3) + WindToGrid = [sum(p.scenario_probabilities[s] * sum(value(m[:dvProductionToGrid][s, t, u, ts]) for u in p.export_bins_by_tech[t]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["electric_to_grid_series_kw"] = round.(WindToGrid, digits=3) r["annual_energy_exported_kwh"] = round( sum(r["electric_to_grid_series_kw"]) * p.hours_per_time_step, digits=0) else WindToGrid = zeros(length(p.time_steps)) + r["electric_to_grid_series_kw"] = WindToGrid end - r["electric_to_grid_series_kw"] = round.(value.(WindToGrid), digits=3) - WindToCUR = (m[Symbol("dvCurtail"*_n)][t, ts] for ts in p.time_steps) - r["electric_curtailed_series_kw"] = round.(value.(WindToCUR), digits=3) + WindToCUR = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvCurtail"*_n)][s, t, ts]) for s in 1:p.n_scenarios) for ts in p.time_steps] + r["electric_curtailed_series_kw"] = round.(WindToCUR, digits=3) - TotalHourlyWindProd = value.(m[Symbol("dvRatedProduction"*_n)][t,ts] * p.production_factor[t, ts] for ts in p.time_steps) + TotalHourlyWindProd = [sum(p.scenario_probabilities[s] * value(m[Symbol("dvRatedProduction"*_n)][s, t,ts]) for s in 1:p.n_scenarios) * p.production_factor[t, ts] for ts in p.time_steps] WindToLoad =(TotalHourlyWindProd[ts] - r["electric_to_storage_series_kw"][ts] - r["electric_to_grid_series_kw"][ts] - r["electric_curtailed_series_kw"][ts] for ts in p.time_steps ) - r["electric_to_load_series_kw"] = round.(value.(WindToLoad), digits=3) + r["electric_to_load_series_kw"] = round.(collect(WindToLoad), digits=3) AvgWindProd = (sum(TotalHourlyWindProd) * p.hours_per_time_step) * p.levelization_factor[t] - r["annual_energy_produced_kwh"] = round(value(AvgWindProd), digits=0) + r["annual_energy_produced_kwh"] = round(AvgWindProd, digits=0) r["lcoe_per_kwh"] = calculate_lcoe(p, r, p.s.wind) d[t] = r diff --git a/test/runtests.jl b/test/runtests.jl index 1da1eeb2d..011a5660d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -373,15 +373,18 @@ else # run HiGHS tests end @testset "Fifteen minute load" begin - d = JSON.parsefile("scenarios/no_techs.json") - d["ElectricLoad"] = Dict("loads_kw" => repeat([1.0], 35040), "year" => 2017) - d["Settings"] = Dict("time_steps_per_hour" => 4) - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, d) - @test results["ElectricLoad"]["annual_calculated_kwh"] ≈ 8760 - finalize(backend(model)) - empty!(model) - GC.gc() + logger = SimpleLogger() + with_logger(logger) do + d = JSON.parsefile("scenarios/no_techs.json") + d["ElectricLoad"] = Dict("loads_kw" => repeat([1.0], 35040), "year" => 2017) + d["Settings"] = Dict("time_steps_per_hour" => 4) + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, d) + @test results["ElectricLoad"]["annual_calculated_kwh"] ≈ 8760 + finalize(backend(model)) + empty!(model) + GC.gc() + end end try @@ -1716,8 +1719,8 @@ else # run HiGHS tests m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => true, "log_to_console" => true, "mip_rel_gap" => 1e-5)) results = run_reopt(m1, p) # demand tier 1 limit = flat load = 100, so no demand charge should be present in Tier 2 - @test sum(value.(m1[Symbol(:dvPeakDemandTOU)][:,2])) ≈ 0.0 atol=1e-6 - @test sum(value.(m1[Symbol(:dvGridPurchase)][:,2])) ≈ 0.0 atol=1e-6 + @test sum(value.(m1[Symbol(:dvPeakDemandTOU)][:,:,2])) ≈ 0.0 atol=1e-6 + @test sum(value.(m1[Symbol(:dvGridPurchase)][:,:,2])) ≈ 0.0 atol=1e-6 @test (results["Financial"]["lcc"]) ≈ 1.092312443e6 rtol=1e-6 end @@ -1831,7 +1834,7 @@ else # run HiGHS tests for ts in p.time_steps #heating and cooling loads only if ts % 2 == 0 #in even periods, there is a nonzero load and energy is higher cost, and storage should discharge - p.s.electric_load.loads_kw[ts] = 10 + p.loads_kw_by_scenario[1][ts] = 10 p.s.dhw_load.loads_kw[ts] = 5 p.s.space_heating_load.loads_kw[ts] = 5 p.s.cooling_load.loads_kw_thermal[ts] = 10 @@ -1840,7 +1843,7 @@ else # run HiGHS tests p.s.electric_tariff.energy_rates[ts, tier] = 100 end else #in odd periods, there is no load and energy is cheaper - storage should charge - p.s.electric_load.loads_kw[ts] = 0 + p.loads_kw_by_scenario[1][ts] = 0 p.s.dhw_load.loads_kw[ts] = 0 p.s.space_heating_load.loads_kw[ts] = 0 p.s.cooling_load.loads_kw_thermal[ts] = 0 @@ -4362,74 +4365,83 @@ else # run HiGHS tests # Update input data for 15-minute intervals input_data["Settings"] = Dict("time_steps_per_hour" => 4) input_data["ElectricLoad"] = Dict("loads_kw" => fifteen_min_loads, "year" => 2023) + + logger = SimpleLogger() + with_logger(logger) do + # Run REopt with 15-minute data + s_fifteen = Scenario(input_data) + inputs_fifteen = REoptInputs(s_fifteen) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) + results_fifteen = run_reopt(m, inputs_fifteen) - # Run REopt with 15-minute data - s_fifteen = Scenario(input_data) - inputs_fifteen = REoptInputs(s_fifteen) - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) - results_fifteen = run_reopt(m, inputs_fifteen) - - # Verify correct time resolution setup - @test length(results_fifteen["ElectricLoad"]["load_series_kw"]) == length(fifteen_min_loads) - @test results_fifteen["ElectricLoad"]["load_series_kw"] ≈ fifteen_min_loads rtol=1e-6 + # Verify correct time resolution setup + @test length(results_fifteen["ElectricLoad"]["load_series_kw"]) == length(fifteen_min_loads) + @test results_fifteen["ElectricLoad"]["load_series_kw"] ≈ fifteen_min_loads rtol=1e-6 - # Verify energy rate series has correct length for 15-minute data - energy_rate_series_15min = results_fifteen["ElectricTariff"]["energy_rate_series"]["Tier_1"] - @test length(energy_rate_series_15min) == 8760 * 4 # 15-minute intervals - @test all(rate -> rate ≈ blended_energy_rate, energy_rate_series_15min) + # Verify energy rate series has correct length for 15-minute data + energy_rate_series_15min = results_fifteen["ElectricTariff"]["energy_rate_series"]["Tier_1"] + @test length(energy_rate_series_15min) == 8760 * 4 # 15-minute intervals + @test all(rate -> rate ≈ blended_energy_rate, energy_rate_series_15min) - # Calculate expected costs manually for 15-minute data - monthly_energy_kwh_15min = results_fifteen["ElectricLoad"]["monthly_calculated_kwh"] - monthly_peaks_kw_15min = results_fifteen["ElectricLoad"]["monthly_peaks_kw"] + # Calculate expected costs manually for 15-minute data + monthly_energy_kwh_15min = results_fifteen["ElectricLoad"]["monthly_calculated_kwh"] + monthly_peaks_kw_15min = results_fifteen["ElectricLoad"]["monthly_peaks_kw"] - expected_energy_costs_15min = monthly_energy_kwh_15min .* blended_energy_rate - actual_energy_costs_15min = results_fifteen["ElectricTariff"]["monthly_energy_cost_series_before_tax"] + expected_energy_costs_15min = monthly_energy_kwh_15min .* blended_energy_rate + actual_energy_costs_15min = results_fifteen["ElectricTariff"]["monthly_energy_cost_series_before_tax"] - expected_demand_costs_15min = monthly_peaks_kw_15min .* blended_demand_rate - actual_demand_costs_15min = results_fifteen["ElectricTariff"]["monthly_demand_cost_series_before_tax"] + expected_demand_costs_15min = monthly_peaks_kw_15min .* blended_demand_rate + actual_demand_costs_15min = results_fifteen["ElectricTariff"]["monthly_demand_cost_series_before_tax"] - # Test 15-minute energy cost alignment - for month in 1:12 - @test actual_energy_costs_15min[month] ≈ expected_energy_costs_15min[month] rtol=0.01 - end + # Test 15-minute energy cost alignment + for month in 1:12 + @test actual_energy_costs_15min[month] ≈ expected_energy_costs_15min[month] rtol=0.01 + end - # Test 15-minute demand cost alignment - for month in 1:12 - @test actual_demand_costs_15min[month] ≈ expected_demand_costs_15min[month] rtol=0.01 - end + # Test 15-minute demand cost alignment + for month in 1:12 + @test actual_demand_costs_15min[month] ≈ expected_demand_costs_15min[month] rtol=0.01 + end - # Verify annual totals consistency - annual_energy_cost_15min = sum(actual_energy_costs_15min) - annual_demand_cost_15min = sum(actual_demand_costs_15min) + # Verify annual totals consistency + annual_energy_cost_15min = sum(actual_energy_costs_15min) + annual_demand_cost_15min = sum(actual_demand_costs_15min) - @test results_fifteen["ElectricTariff"]["year_one_energy_cost_before_tax"] ≈ annual_energy_cost_15min rtol=0.01 - @test results_fifteen["ElectricTariff"]["year_one_demand_cost_before_tax"] ≈ annual_demand_cost_15min rtol=0.01 + @test results_fifteen["ElectricTariff"]["year_one_energy_cost_before_tax"] ≈ annual_energy_cost_15min rtol=0.01 + @test results_fifteen["ElectricTariff"]["year_one_demand_cost_before_tax"] ≈ annual_demand_cost_15min rtol=0.01 - # Verify load metrics consistency for 15-minute data - annual_calc_load_15min = results_fifteen["ElectricLoad"]["annual_calculated_kwh"] - sum_monthly_load_15min = sum(results_fifteen["ElectricLoad"]["monthly_calculated_kwh"]) - annual_peak_15min = results_fifteen["ElectricLoad"]["annual_peak_kw"] - max_monthly_peak_15min = maximum(results_fifteen["ElectricLoad"]["monthly_peaks_kw"]) + # Verify load metrics consistency for 15-minute data + annual_calc_load_15min = results_fifteen["ElectricLoad"]["annual_calculated_kwh"] + sum_monthly_load_15min = sum(results_fifteen["ElectricLoad"]["monthly_calculated_kwh"]) + annual_peak_15min = results_fifteen["ElectricLoad"]["annual_peak_kw"] + max_monthly_peak_15min = maximum(results_fifteen["ElectricLoad"]["monthly_peaks_kw"]) - @test annual_calc_load_15min ≈ sum_monthly_load_15min rtol=0.01 - @test annual_peak_15min ≈ max_monthly_peak_15min rtol=0.01 + @test annual_calc_load_15min ≈ sum_monthly_load_15min rtol=0.01 + @test annual_peak_15min ≈ max_monthly_peak_15min rtol=0.01 - # Test monthly bill accuracy for 15-minute data - for month in 1:12 - total_monthly_cost_15min = actual_energy_costs_15min[month] + actual_demand_costs_15min[month] - expected_monthly_cost_15min = (monthly_energy_kwh_15min[month] * blended_energy_rate) + - (monthly_peaks_kw_15min[month] * blended_demand_rate) - @test total_monthly_cost_15min ≈ expected_monthly_cost_15min rtol=0.01 + # Test monthly bill accuracy for 15-minute data + for month in 1:12 + total_monthly_cost_15min = actual_energy_costs_15min[month] + actual_demand_costs_15min[month] + expected_monthly_cost_15min = (monthly_energy_kwh_15min[month] * blended_energy_rate) + + (monthly_peaks_kw_15min[month] * blended_demand_rate) + @test total_monthly_cost_15min ≈ expected_monthly_cost_15min rtol=0.01 + end + + # Validate that 15-minute energy totals approximately match hourly totals + # (accounting for sine wave variations that should average out over time) + hourly_annual_energy = sum(base_hourly_loads) # Already in kWh for hourly data + fifteen_min_annual_energy = annual_calc_load_15min + @test fifteen_min_annual_energy ≈ hourly_annual_energy rtol=0.05 # 5% tolerance for sine variations + finalize(backend(m)) + empty!(m) + GC.gc() end - - # Validate that 15-minute energy totals approximately match hourly totals - # (accounting for sine wave variations that should average out over time) - hourly_annual_energy = sum(base_hourly_loads) # Already in kWh for hourly data - fifteen_min_annual_energy = annual_calc_load_15min - @test fifteen_min_annual_energy ≈ hourly_annual_energy rtol=0.05 # 5% tolerance for sine variations - finalize(backend(m)) - empty!(m) - GC.gc() + end + + @testset "OUU" begin + include("test_ouu_foundation.jl") + include("test_ouu_run.jl") + include("test_monte_carlo_ouu.jl") end end diff --git a/test/scenarios/ouu_base.json b/test/scenarios/ouu_base.json new file mode 100644 index 000000000..c199b8206 --- /dev/null +++ b/test/scenarios/ouu_base.json @@ -0,0 +1,21 @@ +{ + "Site": { + "latitude": 39.7407, + "longitude": -105.1694 + }, + "ElectricLoad": { + "doe_reference_name": "LargeHotel", + "annual_kwh": 2000000.0 + }, + "ElectricTariff": { + "blended_annual_energy_rate": 0.12, + "blended_annual_demand_rate": 15.0 + }, + "PV": { + "max_kw": 1000.0 + }, + "ElectricStorage": { + "max_kw": 500.0, + "max_kwh": 2000.0 + } +} diff --git a/test/test_monte_carlo_ouu.jl b/test/test_monte_carlo_ouu.jl new file mode 100644 index 000000000..993ff9630 --- /dev/null +++ b/test/test_monte_carlo_ouu.jl @@ -0,0 +1,309 @@ +# using Revise +using JuMP +using HiGHS +# using Xpress +using JSON +using REopt +using Logging +using DotEnv +# using PlotlyJS # Commented out for GitHub Actions +DotEnv.load!() + +# ============================================================================ +# SOLVER CONFIGURATION - Change solver here +# ============================================================================ +const USE_XPRESS = false # Set to false to use HiGHS + +""" +Helper function to create model with configured solver +""" +function create_model(mip_gap::Float64=0.05; verbose::Bool=false) + if USE_XPRESS + m = Model(Xpress.Optimizer) + set_optimizer_attribute(m, "OUTPUTLOG", verbose ? 1 : 0) + set_optimizer_attribute(m, "MIPRELSTOP", mip_gap) + else + m = Model(HiGHS.Optimizer) + set_optimizer_attribute(m, "output_flag", verbose) + set_optimizer_attribute(m, "log_to_console", verbose) + set_optimizer_attribute(m, "mip_rel_gap", mip_gap) + end + return m +end + +println("\n" * "="^80) +println("Testing Monte Carlo vs Discrete OUU Methods") +println("="^80) + +# Suppress info/warning messages +original_logger = Logging.global_logger() +Logging.global_logger(Logging.SimpleLogger(stderr, Logging.Error)) + +# ============================================================================ +# Test 0: Baseline (No Uncertainty) with BAU +# ============================================================================ +println("\n" * "─"^80) +println("Test 0: Baseline (No Uncertainty)") +println("─"^80) +println("Single scenario with no uncertainty, comparing BAU vs technology optimization") + +scenario_baseline = JSON.parsefile("scenarios/ouu_base.json") + +print("Building scenario... ") +s_baseline = Scenario(scenario_baseline) +println("✓") + +print("Building REoptInputs... ") +inputs_baseline = REoptInputs(s_baseline) +println("✓") +println(" └─ Number of scenarios: ", inputs_baseline.n_scenarios) + +# Create BAU and technology optimization models +m_bau = create_model() + +m_tech = create_model() + +print("Building and solving models (BAU + Technology Optimization)... ") +results_baseline = run_reopt([m_bau, m_tech], inputs_baseline) +println("✓") + +if termination_status(m_bau) == MOI.OPTIMAL && termination_status(m_tech) == MOI.OPTIMAL + println("\n📊 Baseline Results:") + println(" BAU (Business as Usual):") + println(" └─ Objective value: \$", round(objective_value(m_bau), digits=2)) + println(" └─ Grid energy supplied: ", round(results_baseline["ElectricUtility"]["annual_energy_supplied_kwh_bau"], digits=1), " kWh") + println("\n Technology Optimal:") + println(" └─ Objective value: \$", round(objective_value(m_tech), digits=2)) + println(" └─ Grid energy supplied: ", round(results_baseline["ElectricUtility"]["annual_energy_supplied_kwh"], digits=1), " kWh") + println(" └─ PV size: ", round(results_baseline["PV"]["size_kw"], digits=1), " kW") + println(" └─ Battery power: ", round(results_baseline["ElectricStorage"]["size_kw"], digits=1), " kW") + println(" └─ Battery energy: ", round(results_baseline["ElectricStorage"]["size_kwh"], digits=1), " kWh") + + savings = objective_value(m_bau) - objective_value(m_tech) + println("\n Technology Savings: \$", round(savings, digits=2), " (", round(savings/objective_value(m_bau)*100, digits=2), "%)") +else + println("\n⚠️ Baseline model status - BAU: ", termination_status(m_bau), ", Tech: ", termination_status(m_tech)) +end + +# ============================================================================ +# Test 1: Time-Invariant Method (Original Implementation) +# ============================================================================ +println("\n" * "─"^80) +println("Test 1: Time-Invariant Method") +println("─"^80) +println("Creates 3 load scenarios × 3 PV scenarios = 9 total scenarios") +println("Each scenario has uniform deviation across all timesteps") + +scenario_invariant = JSON.parsefile("scenarios/ouu_base.json") +scenario_invariant["ElectricLoad"]["uncertainty"] = Dict( + "enabled" => true, + "method" => "time_invariant", + "deviation_fractions" => [-0.1, 0.0, 0.1], + "deviation_probabilities" => [0.25, 0.50, 0.25] +) +scenario_invariant["PV"]["production_uncertainty"] = Dict( + "enabled" => true, + "method" => "time_invariant", + "deviation_fractions" => [-0.2, 0.0, 0.2], + "deviation_probabilities" => [0.25, 0.50, 0.25] +) + +print("Building scenario... ") +s_invariant = Scenario(scenario_invariant) +println("✓") + +print("Building REoptInputs... ") +inputs_invariant = REoptInputs(s_invariant) +println("✓") +println(" └─ Number of scenarios: ", inputs_invariant.n_scenarios) +println(" └─ Scenario probabilities: ", round.(inputs_invariant.scenario_probabilities[1:min(10, end)], digits=4)) + +m_invariant = create_model() + +m_invariant_bau = create_model() + +print("Building and solving models (BAU + Technology Optimization)... ") +results_invariant = run_reopt([m_invariant_bau, m_invariant], inputs_invariant) +println("✓") + +if termination_status(m_invariant) == MOI.OPTIMAL + println("\n📊 Time-Invariant Method Results:") + println(" Technology Optimal:") + println(" └─ Objective value: \$", round(objective_value(m_invariant), digits=2)) + println(" └─ Grid energy supplied: ", round(results_invariant["ElectricUtility"]["annual_energy_supplied_kwh"], digits=1), " kWh") + println(" └─ PV size: ", round(results_invariant["PV"]["size_kw"], digits=1), " kW") + println(" └─ Battery power: ", round(results_invariant["ElectricStorage"]["size_kw"], digits=1), " kW") + println(" └─ Battery energy: ", round(results_invariant["ElectricStorage"]["size_kwh"], digits=1), " kWh") + if termination_status(m_invariant_bau) == MOI.OPTIMAL + println("\n BAU:") + println(" └─ Objective value: \$", round(objective_value(m_invariant_bau), digits=2)) + println(" └─ Grid energy supplied: ", round(results_invariant["ElectricUtility"]["annual_energy_supplied_kwh_bau"], digits=1), " kWh") + savings = objective_value(m_invariant_bau) - objective_value(m_invariant) + println(" └─ Technology Savings: \$", round(savings, digits=2), " (", round(savings/objective_value(m_invariant_bau)*100, digits=2), "%)") + end +else + println("\n⚠️ Time-Invariant model status: ", termination_status(m_invariant)) +end + +# ============================================================================ +# Test 2: Monte Carlo Method (New Implementation) +# ============================================================================ +println("\n" * "─"^80) +println("Test 2: Monte Carlo Method") +println("─"^80) +println("Creates 9 scenarios (3 load samples × 3 PV samples)") +println("Each scenario has timestep-varying deviations sampled from distribution") + +scenario_mc = JSON.parsefile("scenarios/ouu_base.json") +scenario_mc["ElectricLoad"]["uncertainty"] = Dict( + "enabled" => true, + "method" => "discrete", + "deviation_fractions" => [-0.1, 0.0, 0.1], + "deviation_probabilities" => [0.25, 0.50, 0.25], + "n_samples" => 3 # Each sample has different deviation per timestep +) +scenario_mc["PV"]["production_uncertainty"] = Dict( + "enabled" => true, + "method" => "discrete", + "deviation_fractions" => [-0.2, 0.0, 0.2], + "deviation_probabilities" => [0.25, 0.50, 0.25], + "n_samples" => 3 +) + +print("Building scenario... ") +s_mc = Scenario(scenario_mc) +println("✓") + +print("Building REoptInputs... ") +inputs_mc = REoptInputs(s_mc) +println("✓") +println(" └─ Number of scenarios: ", inputs_mc.n_scenarios) +println(" └─ Scenario probabilities (first 5): ", round.(inputs_mc.scenario_probabilities[1:min(5, end)], digits=4)) +println(" └─ All equal? ", all(x -> isapprox(x, inputs_mc.scenario_probabilities[1], atol=1e-10), inputs_mc.scenario_probabilities)) + +m_mc = create_model() + +m_mc_bau = create_model() + +print("Building and solving models (BAU + Technology Optimization)... ") +results_mc = run_reopt([m_mc_bau, m_mc], inputs_mc) +println("✓") + +if termination_status(m_mc) == MOI.OPTIMAL + println("\n📊 Monte Carlo Method Results:") + println(" Technology Optimal:") + println(" └─ Objective value: \$", round(objective_value(m_mc), digits=2)) + println(" └─ Grid energy supplied: ", round(results_mc["ElectricUtility"]["annual_energy_supplied_kwh"], digits=1), " kWh") + println(" └─ PV size: ", round(results_mc["PV"]["size_kw"], digits=1), " kW") + println(" └─ Battery power: ", round(results_mc["ElectricStorage"]["size_kw"], digits=1), " kW") + println(" └─ Battery energy: ", round(results_mc["ElectricStorage"]["size_kwh"], digits=1), " kWh") + if termination_status(m_mc_bau) == MOI.OPTIMAL + println("\n BAU:") + println(" └─ Objective value: \$", round(objective_value(m_mc_bau), digits=2)) + println(" └─ Grid energy supplied: ", round(results_mc["ElectricUtility"]["annual_energy_supplied_kwh_bau"], digits=1), " kWh") + savings = objective_value(m_mc_bau) - objective_value(m_mc) + println(" └─ Technology Savings: \$", round(savings, digits=2), " (", round(savings/objective_value(m_mc_bau)*100, digits=2), "%)") + end +else + println("\n⚠️ Monte Carlo model status: ", termination_status(m_mc)) +end + +# ============================================================================ +# Comparison +# ============================================================================ +if termination_status(m_invariant) == MOI.OPTIMAL && termination_status(m_mc) == MOI.OPTIMAL + println("\n" * "─"^80) + println("Comparison:") + println("─"^80) + + pv_diff = results_mc["PV"]["size_kw"] - results_invariant["PV"]["size_kw"] + batt_kw_diff = results_mc["ElectricStorage"]["size_kw"] - results_invariant["ElectricStorage"]["size_kw"] + batt_kwh_diff = results_mc["ElectricStorage"]["size_kwh"] - results_invariant["ElectricStorage"]["size_kwh"] + cost_diff = objective_value(m_mc) - objective_value(m_invariant) + + println(" Δ PV size: ", round(pv_diff, digits=1), " kW (", round(pv_diff/results_invariant["PV"]["size_kw"]*100, digits=1), "%)") + println(" Δ Battery power: ", round(batt_kw_diff, digits=1), " kW (", round(batt_kw_diff/results_invariant["ElectricStorage"]["size_kw"]*100, digits=1), "%)") + println(" Δ Battery energy: ", round(batt_kwh_diff, digits=1), " kWh (", round(batt_kwh_diff/results_invariant["ElectricStorage"]["size_kwh"]*100, digits=1), "%)") + println(" Δ Cost: \$", round(cost_diff, digits=2), " (", round(cost_diff/objective_value(m_invariant)*100, digits=2), "%)") + + println("\n💡 Key Insight:") + println(" Monte Carlo captures timestep-level uncertainty, while discrete assumes") + println(" all timesteps move together. This can lead to different optimal sizing.") + + # ============================================================================ + # Visualization: Compare scenario profiles + # ============================================================================ + println("\n" * "─"^80) + println("Plotting scenario profiles...") + println("─"^80) + + # Plot first 168 hours (1 week) for visibility + # Plotting code commented out for GitHub Actions (PlotlyJS not available) + # plot_hours = 1:min(8760, length(inputs_invariant.time_steps)) + # + # # Find baseline scenario (zero deviation) in time_invariant - should be scenario 2 with deviation=0.0 + # baseline_scenario_id = 5 # Middle scenario with 0.0 deviation + # mc_scenario_id = 2 # First Monte Carlo scenario + # + # # Create load comparison plot + # load_trace1 = PlotlyJS.scatter( + # x=collect(plot_hours), + # y=inputs_discrete.loads_kw_by_scenario[baseline_scenario_id][plot_hours], + # mode="lines", + # name="Baseline (No Deviation)", + # line=attr(width=2, color="blue") + # ) + # load_trace2 = PlotlyJS.scatter( + # x=collect(plot_hours), + # y=inputs_mc.loads_kw_by_scenario[mc_scenario_id][plot_hours], + # mode="lines", + # name="Discrete Monte Carlo Sample", + # line=attr(width=2, color="red", dash="dash") + # ) + # + # load_layout = PlotlyJS.Layout( + # title="Load Profile Comparison (First Week)", + # xaxis_title="Hour", + # yaxis_title="Load (kW)", + # hovermode="x unified" + # ) + # + # load_plot = PlotlyJS.plot([load_trace1, load_trace2], load_layout) + # + # # Create PV production comparison plot + # pv_trace1 = PlotlyJS.scatter( + # x=collect(plot_hours), + # y=inputs_discrete.production_factor_by_scenario[baseline_scenario_id]["PV"][plot_hours], + # mode="lines", + # name="Baseline (No Deviation)", + # line=attr(width=2, color="blue") + # ) + # pv_trace2 = PlotlyJS.scatter( + # x=collect(plot_hours), + # y=inputs_mc.production_factor_by_scenario[mc_scenario_id]["PV"][plot_hours], + # mode="lines", + # name="Discrete Monte Carlo Sample", + # line=attr(width=2, color="red", dash="dash") + # ) + # + # pv_layout = PlotlyJS.Layout( + # title="PV Production Profile Comparison (First Week)", + # xaxis_title="Hour", + # yaxis_title="Production Factor", + # hovermode="x unified" + # ) + # + # pv_plot = PlotlyJS.plot([pv_trace1, pv_trace2], pv_layout) + # + # # Save plots + # PlotlyJS.savefig(load_plot, "load_comparison.html") + # PlotlyJS.savefig(pv_plot, "pv_comparison.html") + # println(" └─ Plots saved to load_comparison.html and pv_comparison.html") +end + +# Restore logger +Logging.global_logger(original_logger) + +println("\n" * "="^80) +println("✅ Tests Complete") +println("="^80) diff --git a/test/test_ouu_foundation.jl b/test/test_ouu_foundation.jl new file mode 100644 index 000000000..c0e25f805 --- /dev/null +++ b/test/test_ouu_foundation.jl @@ -0,0 +1,501 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. +# Test suite for Optimization Under Uncertainty (OUU) implementation +# Tests validate expected behavior and impacts of uncertainty on results +# using Revise +using Test +using JuMP +using HiGHS +# using Xpress +using JSON +using REopt +using DotEnv +DotEnv.load!() + +# ============================================================================ +# SOLVER CONFIGURATION - Change solver here +# ============================================================================ +const USE_XPRESS = false # Set to false to use HiGHS + +""" +Helper function to create model with configured solver +""" +function create_model(mip_gap::Float64=0.05; verbose::Bool=false) + if USE_XPRESS + m = Model(Xpress.Optimizer) + set_optimizer_attribute(m, "OUTPUTLOG", verbose ? 1 : 0) + set_optimizer_attribute(m, "MIPRELSTOP", mip_gap) + else + m = Model(HiGHS.Optimizer) + set_optimizer_attribute(m, "output_flag", verbose) + set_optimizer_attribute(m, "log_to_console", verbose) + set_optimizer_attribute(m, "mip_rel_gap", mip_gap) + end + return m +end + +""" +Helper function to create base scenario dict for OUU testing +""" +function create_base_ouu_scenario(; + load_uncertainty_enabled=false, + load_deviation=0.0, + pv_uncertainty_enabled=false, + pv_deviation=0.0, + annual_kwh=1000000.0, + pv_max_kw=500.0 +) + scenario = Dict{String, Any}( + "Site" => Dict{String, Any}( + "latitude" => 39.7407, + "longitude" => -105.1694 + ), + "ElectricLoad" => Dict{String, Any}( + "annual_kwh" => annual_kwh, + "doe_reference_name" => "LargeOffice" + ), + "ElectricTariff" => Dict{String, Any}( + "blended_annual_energy_rate" => 0.10, + "blended_annual_demand_rate" => 0.0 # Disable demand charges for simplicity + ), + "PV" => Dict{String, Any}( + "max_kw" => pv_max_kw + ), + "ElectricStorage" => Dict{String, Any}( + "max_kw" => 500.0, + "max_kwh" => 2000.0 + ) + ) + + # Add load uncertainty if requested + if load_uncertainty_enabled + scenario["ElectricLoad"]["uncertainty"] = Dict{String, Any}( + "enabled" => true, + "deviation_fractions" => [-load_deviation, 0.0, load_deviation], + "deviation_probabilities" => [0.25, 0.50, 0.25] + ) + end + + # Add PV uncertainty if requested + if pv_uncertainty_enabled + scenario["PV"]["production_uncertainty"] = Dict{String, Any}( + "enabled" => true, + "deviation_fractions" => [-pv_deviation, 0.0, pv_deviation], + "deviation_probabilities" => [0.25, 0.50, 0.25] + ) + end + + return scenario +end + +""" +Helper function to run optimization and return key results +""" +function run_ouu_test(scenario_dict) + m = create_model() + + s = Scenario(scenario_dict) + inputs = REoptInputs(s) + results = run_reopt(m, inputs) + + return ( + inputs = inputs, + results = results, + pv_size = results["PV"]["size_kw"], + battery_power = results["ElectricStorage"]["size_kw"], + battery_energy = results["ElectricStorage"]["size_kwh"], + objective = results["Financial"]["lcc"] + ) +end + + +@testset verbose=true "OUU Foundation Tests" begin + + @testset "Scenario Generation" begin + @testset "Load Scenarios - Single Uncertainty" begin + scenario = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.1 + ) + + s = Scenario(scenario) + inputs = REoptInputs(s) + + # Should create 3 scenarios (low, mid, high) + @test inputs.n_scenarios == 3 + + # Probabilities should sum to 1 + @test sum(inputs.scenario_probabilities) ≈ 1.0 atol=1e-6 + + # Probabilities should match specification + @test inputs.scenario_probabilities ≈ [0.25, 0.50, 0.25] + + # Check load scaling + nominal_load = s.electric_load.loads_kw + for ts in 1:length(nominal_load) + @test inputs.loads_kw_by_scenario[1][ts] ≈ nominal_load[ts] * 0.9 atol=1e-6 + @test inputs.loads_kw_by_scenario[2][ts] ≈ nominal_load[ts] atol=1e-6 + @test inputs.loads_kw_by_scenario[3][ts] ≈ nominal_load[ts] * 1.1 atol=1e-6 + end + end + + @testset "PV Scenarios - Single Uncertainty" begin + scenario = create_base_ouu_scenario( + pv_uncertainty_enabled=true, + pv_deviation=0.2 + ) + + s = Scenario(scenario) + inputs = REoptInputs(s) + + # Should create 3 scenarios + @test inputs.n_scenarios == 3 + @test sum(inputs.scenario_probabilities) ≈ 1.0 + + # Check PV production factor scaling + @test haskey(inputs.production_factor_by_scenario[1], "PV") + + # Get nominal factor from deterministic run + scenario_det = create_base_ouu_scenario() + s_det = Scenario(scenario_det) + inputs_det = REoptInputs(s_det) + nominal_pf = inputs_det.production_factor["PV", :] + + for ts in 1:length(nominal_pf) + @test inputs.production_factor_by_scenario[1]["PV"][ts] ≈ nominal_pf[ts] * 0.8 atol=1e-6 + @test inputs.production_factor_by_scenario[2]["PV"][ts] ≈ nominal_pf[ts] atol=1e-6 + @test inputs.production_factor_by_scenario[3]["PV"][ts] ≈ nominal_pf[ts] * 1.2 atol=1e-6 + end + end + + @testset "Combined Scenarios - Both Uncertainties" begin + scenario = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.1, + pv_uncertainty_enabled=true, + pv_deviation=0.15 + ) + + s = Scenario(scenario) + inputs = REoptInputs(s) + + # Should create 9 combined scenarios + @test inputs.n_scenarios == 9 + + # Probabilities should sum to 1 + @test sum(inputs.scenario_probabilities) ≈ 1.0 atol=1e-6 + + # Check joint probabilities (independence assumption) + expected_probs = [ + 0.25*0.25, 0.25*0.50, 0.25*0.25, # Low load with low/mid/high PV + 0.50*0.25, 0.50*0.50, 0.50*0.25, # Mid load + 0.25*0.25, 0.25*0.50, 0.25*0.25 # High load + ] + @test inputs.scenario_probabilities ≈ expected_probs atol=1e-6 + end + end + + @testset "Uncertainty Impact on Sizing" begin + @testset "Uncertainty Impact on Sizing and Cost" begin + println("\n Testing how uncertainty affects sizing and cost...") + + # Test with increasing load uncertainty + deviations = [0.0, 0.05, 0.10, 0.20] + pv_sizes = Float64[] + battery_powers = Float64[] + objectives = Float64[] + + for dev in deviations + scenario = create_base_ouu_scenario( + load_uncertainty_enabled = (dev > 0), + load_deviation = dev, + annual_kwh = 1000000.0, + pv_max_kw = 1000.0 + ) + + result = run_ouu_test(scenario) + push!(pv_sizes, result.pv_size) + push!(battery_powers, result.battery_power) + push!(objectives, result.objective) + end + + println(" Deviation | PV Size | Battery | Objective") + for i in 1:length(deviations) + println(" $(lpad(Int(deviations[i]*100), 3))% | $(lpad(round(pv_sizes[i], digits=1), 7)) | $(lpad(round(battery_powers[i], digits=1), 7)) | \$$(round(objectives[i], digits=0))") + end + + # Objective (expected cost) should be non-decreasing with uncertainty + # (accounting for more scenarios can't decrease expected cost) + for i in 2:length(objectives) + @test objectives[i] >= objectives[i-1] - 10.0 # Small tolerance for numerical issues + end + + # Sizing relationship is problem-dependent - just verify solutions are valid + @test all(pv_sizes .>= 0.0) + @test all(battery_powers .>= 0.0) + + # Print sizing trends for analysis + println("\n Sizing may increase, decrease, or stay similar depending on:") + println(" - Electricity rate structure") + println(" - Capital vs operating cost trade-offs") + println(" - Probability distribution of scenarios") + end + + @testset "Boundary Test: Zero Uncertainty = Deterministic" begin + println("\n Testing boundary condition (0% uncertainty = deterministic)...") + + # Deterministic scenario + scenario_det = create_base_ouu_scenario( + load_uncertainty_enabled=false, + pv_uncertainty_enabled=false + ) + result_det = run_ouu_test(scenario_det) + + # OUU with zero uncertainty + scenario_ouu_zero = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.0, + pv_uncertainty_enabled=true, + pv_deviation=0.0 + ) + result_ouu_zero = run_ouu_test(scenario_ouu_zero) + + println(" Deterministic: PV=$(round(result_det.pv_size, digits=1)) kW, Battery=$(round(result_det.battery_power, digits=1)) kW") + println(" OUU (0% dev): PV=$(round(result_ouu_zero.pv_size, digits=1)) kW, Battery=$(round(result_ouu_zero.battery_power, digits=1)) kW") + + # Results should be very similar (allowing for numerical differences) + @test result_ouu_zero.pv_size ≈ result_det.pv_size rtol=0.02 + @test result_ouu_zero.battery_power ≈ result_det.battery_power rtol=0.02 + @test result_ouu_zero.objective ≈ result_det.objective rtol=0.01 + end + + @testset "Economic Optimization: OUU vs Individual Scenarios" begin + println("\n Testing OUU economic optimization vs individual scenarios...") + + # Run OUU with combined uncertainty + scenario_ouu = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.1, + pv_uncertainty_enabled=true, + pv_deviation=0.15, + annual_kwh=1000000.0 + ) + result_ouu = run_ouu_test(scenario_ouu) + + # Run deterministic for worst case: High load + Low PV + scenario_worst = create_base_ouu_scenario( + annual_kwh=1100000.0 # 110% of base + ) + scenario_worst["PV"]["max_kw"] = 500.0 * 0.85 # Effectively lower production + result_worst = run_ouu_test(scenario_worst) + + # Best case: Low load + High PV + scenario_best = create_base_ouu_scenario( + annual_kwh=900000.0 # 90% of base + ) + scenario_best["PV"]["max_kw"] = 500.0 * 1.15 + result_best = run_ouu_test(scenario_best) + + # Middle/expected case + scenario_mid = create_base_ouu_scenario( + annual_kwh=1000000.0 + ) + result_mid = run_ouu_test(scenario_mid) + + println(" Scenario | PV Size | Battery | Cost") + println(" Best case | $(lpad(round(result_best.pv_size, digits=1), 7)) | $(lpad(round(result_best.battery_power, digits=1), 7)) | \$$(round(result_best.objective, digits=0))") + println(" Expected case | $(lpad(round(result_mid.pv_size, digits=1), 7)) | $(lpad(round(result_mid.battery_power, digits=1), 7)) | \$$(round(result_mid.objective, digits=0))") + println(" Worst case | $(lpad(round(result_worst.pv_size, digits=1), 7)) | $(lpad(round(result_worst.battery_power, digits=1), 7)) | \$$(round(result_worst.objective, digits=0))") + println(" OUU (robust) | $(lpad(round(result_ouu.pv_size, digits=1), 7)) | $(lpad(round(result_ouu.battery_power, digits=1), 7)) | \$$(round(result_ouu.objective, digits=0))") + + # OUU sizing finds economic optimum - doesn't necessarily match any single scenario + # Just verify it's within reasonable bounds + @test result_ouu.pv_size >= min(result_best.pv_size, result_mid.pv_size, result_worst.pv_size) * 0.80 + @test result_ouu.pv_size <= max(result_best.pv_size, result_mid.pv_size, result_worst.pv_size) * 1.20 + + println("\n OUU finds economically optimal sizing that balances:") + println(" - Capital costs (sizing decisions)") + println(" - Expected operating costs (across all scenarios)") + println(" - Probability-weighted performance") + end + end + + @testset "Uncertainty Impact on Costs" begin + @testset "Expected Cost Relationship: OUU vs Deterministic" begin + println("\n Testing expected cost relationship...") + + # Deterministic (expected case only) + scenario_det = create_base_ouu_scenario() + result_det = run_ouu_test(scenario_det) + + # OUU with 10% uncertainty + scenario_ouu = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10, + pv_uncertainty_enabled=true, + pv_deviation=0.10 + ) + result_ouu = run_ouu_test(scenario_ouu) + + cost_increase = result_ouu.objective - result_det.objective + cost_increase_pct = 100 * cost_increase / result_det.objective + + println(" Deterministic LCC: \$$(round(result_det.objective, digits=0))") + println(" OUU LCC: \$$(round(result_ouu.objective, digits=0))") + println(" Difference: \$$(round(cost_increase, digits=0)) ($(round(cost_increase_pct, digits=2))%)") + + # OUU expected cost typically >= deterministic cost at expected case + # (must perform well across all scenarios, not just expected case) + @test result_ouu.objective >= result_det.objective * 0.98 # Allow small tolerance + + println("\n Note: OUU objective accounts for probability-weighted costs") + println(" across all scenarios, while deterministic only considers") + println(" the expected case. Difference represents value of robustness.") + end + + @testset "Cost Scaling with Uncertainty Magnitude" begin + println("\n Testing cost scaling with uncertainty magnitude...") + + deviations = [0.05, 0.10, 0.15, 0.20] + costs = Float64[] + + base_scenario = create_base_ouu_scenario() + base_result = run_ouu_test(base_scenario) + base_cost = base_result.objective + + for dev in deviations + scenario = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=dev + ) + result = run_ouu_test(scenario) + push!(costs, result.objective) + end + + println(" Deviation | LCC | Premium vs Det") + println(" Base (0%) | \$$(round(base_cost, digits=0)) | --") + for i in 1:length(deviations) + premium_pct = 100 * (costs[i] - base_cost) / base_cost + println(" $(lpad(Int(deviations[i]*100), 3))% | \$$(round(costs[i], digits=0)) | $(round(premium_pct, digits=2))%") + end + + # Costs should increase monotonically + for i in 2:length(costs) + @test costs[i] >= costs[i-1] + end + + # All OUU costs should exceed deterministic + for cost in costs + @test cost >= base_cost + end + end + end + + @testset "Independent vs Combined Uncertainty" begin + println("\n Testing interaction of load and PV uncertainty...") + + # Only load uncertainty + scenario_load = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10 + ) + result_load = run_ouu_test(scenario_load) + + # Only PV uncertainty + scenario_pv = create_base_ouu_scenario( + pv_uncertainty_enabled=true, + pv_deviation=0.10 + ) + result_pv = run_ouu_test(scenario_pv) + + # Both uncertainties + scenario_both = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10, + pv_uncertainty_enabled=true, + pv_deviation=0.10 + ) + result_both = run_ouu_test(scenario_both) + + println(" Scenario | n_scen | PV Size | Battery | Cost") + println(" Load only | $(result_load.inputs.n_scenarios) | $(lpad(round(result_load.pv_size, digits=1), 7)) | $(lpad(round(result_load.battery_power, digits=1), 7)) | \$$(round(result_load.objective, digits=0))") + println(" PV only | $(result_pv.inputs.n_scenarios) | $(lpad(round(result_pv.pv_size, digits=1), 7)) | $(lpad(round(result_pv.battery_power, digits=1), 7)) | \$$(round(result_pv.objective, digits=0))") + println(" Both | $(result_both.inputs.n_scenarios) | $(lpad(round(result_both.pv_size, digits=1), 7)) | $(lpad(round(result_both.battery_power, digits=1), 7)) | \$$(round(result_both.objective, digits=0))") + + # Scenarios should combine multiplicatively + @test result_load.inputs.n_scenarios == 3 + @test result_pv.inputs.n_scenarios == 3 + @test result_both.inputs.n_scenarios == 9 + + # Combined sizing should be at least as large as individual + @test result_both.pv_size >= max(result_load.pv_size, result_pv.pv_size) * 0.95 + @test result_both.battery_power >= max(result_load.battery_power, result_pv.battery_power) * 0.95 + + # Combined cost should exceed individual costs + @test result_both.objective >= max(result_load.objective, result_pv.objective) + end + + @testset "Probability Distribution Impact" begin + println("\n Testing different probability distributions...") + + # Symmetric distribution (baseline) + scenario_sym = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10 + ) + scenario_sym["ElectricLoad"]["uncertainty"]["deviation_probabilities"] = [0.25, 0.50, 0.25] + result_sym = run_ouu_test(scenario_sym) + + # Pessimistic distribution (higher weight on high load) + scenario_pess = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10 + ) + scenario_pess["ElectricLoad"]["uncertainty"]["deviation_probabilities"] = [0.10, 0.40, 0.50] + result_pess = run_ouu_test(scenario_pess) + + # Optimistic distribution (higher weight on low load) + scenario_opt = create_base_ouu_scenario( + load_uncertainty_enabled=true, + load_deviation=0.10 + ) + scenario_opt["ElectricLoad"]["uncertainty"]["deviation_probabilities"] = [0.50, 0.40, 0.10] + result_opt = run_ouu_test(scenario_opt) + + println(" Distribution | PV Size | Battery | Cost") + println(" Symmetric | $(lpad(round(result_sym.pv_size, digits=1), 7)) | $(lpad(round(result_sym.battery_power, digits=1), 7)) | \$$(round(result_sym.objective, digits=0))") + println(" Pessimistic | $(lpad(round(result_pess.pv_size, digits=1), 7)) | $(lpad(round(result_pess.battery_power, digits=1), 7)) | \$$(round(result_pess.objective, digits=0))") + println(" Optimistic | $(lpad(round(result_opt.pv_size, digits=1), 7)) | $(lpad(round(result_opt.battery_power, digits=1), 7)) | \$$(round(result_opt.objective, digits=0))") + + # Pessimistic should have larger sizing (hedging against high load) + @test result_pess.pv_size >= result_opt.pv_size * 0.98 # May be close + @test result_pess.battery_power >= result_opt.battery_power * 0.98 + + # Pessimistic should have higher expected cost + @test result_pess.objective >= result_opt.objective * 0.99 + end + + @testset "Backward Compatibility" begin + println("\n Testing backward compatibility (uncertainty disabled)...") + + # Scenario without uncertainty keys + scenario_old = create_base_ouu_scenario() + + # Should not error + s = Scenario(scenario_old) + @test !s.load_uncertainty.enabled + @test !s.production_uncertainty.enabled + + inputs = REoptInputs(s) + @test inputs.n_scenarios == 1 + @test inputs.scenario_probabilities == [1.0] + + # Should produce valid results + result = run_ouu_test(scenario_old) + @test result.pv_size >= 0.0 + @test result.objective > 0.0 + end +end + +println("\n" * "="^70) +println("✅ OUU Foundation Tests Complete") +println("="^70) diff --git a/test/test_ouu_run.jl b/test/test_ouu_run.jl new file mode 100644 index 000000000..6b84ad310 --- /dev/null +++ b/test/test_ouu_run.jl @@ -0,0 +1,156 @@ +# using Revise +using JuMP +using HiGHS +# using Xpress +using JSON +using REopt +using Logging +using DotEnv +DotEnv.load!() + +# ============================================================================ +# SOLVER CONFIGURATION - Change solver here +# ============================================================================ +const USE_XPRESS = false # Set to true to use Xpress + +""" +Helper function to create model with configured solver +""" +function create_model(mip_gap::Float64=0.05; verbose::Bool=false) + if USE_XPRESS + m = Model(Xpress.Optimizer) + set_optimizer_attribute(m, "OUTPUTLOG", verbose ? 1 : 0) + set_optimizer_attribute(m, "MIPRELSTOP", mip_gap) + else + m = Model(HiGHS.Optimizer) + set_optimizer_attribute(m, "output_flag", verbose) + set_optimizer_attribute(m, "log_to_console", verbose) + set_optimizer_attribute(m, "mip_rel_gap", mip_gap) + end + return m +end + +# Create a minimal test scenario with OUU +scenario = Dict( + "Site" => Dict( + "latitude" => 39.7407, + "longitude" => -105.1694, + "min_resil_time_steps" => 4 + ), + "ElectricLoad" => Dict( + "doe_reference_name" => "LargeHotel", + "annual_kwh" => 2000000.0, + "uncertainty" => Dict( + "enabled" => true, + "method" => "time_invariant", + "deviation_fractions" => [-0.1, 0.0, 0.1], + "deviation_probabilities" => [0.25, 0.50, 0.25] + ), + "critical_load_fraction" => 1.0 + ), + # "ElectricUtility" => Dict( + # "outage_start_time_steps" => [19, 5214], + # "outage_durations" => [4] + # ), + "ElectricTariff" => Dict( + "urdb_label" => "5ed6c1a15457a3367add15ae" + # "blended_annual_energy_rate" => 0.08, + # "blended_annual_demand_rate" => 10.0 + ), + "PV" => Dict( + "max_kw" => 2000.0, + "production_uncertainty" => Dict( + "enabled" => true, + "method" => "time_invariant", + "deviation_fractions" => [-0.2, 0.0],# 0.2], + "deviation_probabilities" => [0.25, 0.75],# 0.25] + ) + ), + "ElectricStorage" => Dict( + "max_kw" => 2000.0, + "max_kwh" => 10000.0 + ) +) + +# Write scenario to JSON file +# open("scenarios/ouu_outages.json", "w") do f +# JSON.print(f, scenario, 4) +# end + +println("\n" * "="^60) +println("Testing OUU Implementation") +println("="^60) + +# try +# Suppress info/warning messages temporarily +original_logger = Logging.global_logger() +Logging.global_logger(Logging.SimpleLogger(stderr, Logging.Error)) + +# Create model with logging enabled +m = create_model(verbose=true) + +print("Building Scenario... ") +s = Scenario(scenario) +println("✓") + +print("Building REoptInputs... ") +inputs = REoptInputs(s) +println("✓") +println(" └─ n_scenarios: ", inputs.n_scenarios) +println(" └─ probabilities: ", round.(inputs.scenario_probabilities, digits=4)) + +print("Building optimization model... ") +build_reopt!(m, inputs) +println("✓") +println(" └─ Variables: ", num_variables(m)) +println(" └─ Constraints: ", num_constraints(m, AffExpr, MOI.EqualTo{Float64})) + +print("Solving... ") +optimize!(m) + +# Restore logger +Logging.global_logger(original_logger) + +if termination_status(m) == MOI.OPTIMAL + println("✓") + println("\n✅ SUCCESS: Model solved to optimality") + println(" └─ Objective value: \$", round(objective_value(m), digits=2)) + + print("\nProcessing results... ") + results = reopt_results(m, inputs) + println("✓") + + println("\n📊 Key Results:") + println(" PV:") + if haskey(results, "PV") + println(" └─ Size: ", results["PV"]["size_kw"], " kW") + println(" └─ Annual production: ", results["PV"]["annual_energy_produced_kwh"], " kWh") + end + + println(" Battery:") + if haskey(results, "ElectricStorage") + println(" └─ Power: ", results["ElectricStorage"]["size_kw"], " kW") + println(" └─ Energy: ", results["ElectricStorage"]["size_kwh"], " kWh") + end + + println(" Grid:") + if haskey(results, "ElectricUtility") + println(" └─ Annual energy: ", results["ElectricUtility"]["annual_energy_supplied_kwh"], " kWh") + end +else + println("✗") + println("\n⚠️ Model status: ", termination_status(m)) +end + +# catch e +# println("\n" * "="^60) +# println("❌ ERROR ENCOUNTERED") +# println("="^60) +# showerror(stdout, e) +# println("\n") +# for (exc, bt) in Base.catch_stack() +# showerror(stdout, exc, bt) +# println() +# end +# println("="^60) +# end