diff --git a/independent_thermal_chp_implementation.md b/independent_thermal_chp_implementation.md new file mode 100644 index 000000000..752aab308 --- /dev/null +++ b/independent_thermal_chp_implementation.md @@ -0,0 +1,477 @@ +# Independent Thermal CHP Implementation + +## Overview + +This update adds the capability for CHP (Combined Heat and Power) systems to produce thermal energy independently of electric production. This enhancement enables modeling of technologies like nuclear or geothermal systems where thermal energy can be produced separately and more efficiently than when coupled to electric generation. + +### Key Capability + +Previously, CHP thermal production was **coupled** to electric production - thermal output was proportional to electric output based on efficiency curves. Now, with `can_produce_thermal_independently = true`, the CHP can: + +- Produce thermal energy without producing electricity +- Produce electricity (which consumes thermal capacity from the source) +- Produce both thermal and electric simultaneously +- Size electric capacity and thermal capacity independently + +**Important Physics for Nuclear/Geothermal:** +- `size_thermal_kw` = thermal source capacity (reactor thermal power, wellfield output) +- `size_kw` = electric generation equipment capacity (turbine/generator) +- Total thermal from source = thermal to loads + thermal converted to electricity +- Thermal for electricity = electric production / electric_efficiency +- Constraint: thermal_to_loads + thermal_for_electric ≤ thermal_source_capacity + +## Files Modified + +### 1. Core CHP Definition: `src/core/chp.jl` + +**Added Fields to CHP Struct:** + +```julia +can_produce_thermal_independently::Bool = false # Enable independent thermal production +min_thermal_kw::Float64 = 0.0 # Minimum thermal capacity constraint +max_thermal_kw::Float64 = NaN # Maximum thermal capacity constraint +``` + +**Docstring Updated:** Added documentation for the three new parameters with usage notes. + +### 2. CHP Constraints: `src/constraints/chp_constraints.jl` + +**New Functions Added:** + +- `add_chp_independent_thermal_production_constraints(m, p; _n="")` + - Models thermal source capacity (reactor, wellfield) that serves both loads and electric production + - Constrains: thermal_to_loads + thermal_for_electric_conversion ≤ thermal_source_capacity + - Where: thermal_for_electric = electric_production / electric_efficiency + - Enforces thermal capacity bounds (min/max) + +- `add_chp_independent_fuel_burn_constraints(m, p; _n="")` + - Calculates fuel consumption based on total thermal from source + - Fuel = (thermal_to_loads + thermal_for_electric) / thermal_efficiency_of_source + - Properly represents nuclear/geothermal physics where fuel produces thermal first + - Thermal is then either used directly or converted to electricity + +**Modified Functions:** + +- `add_chp_constraints(m, p; _n="")` + - Creates `dvThermalSize` decision variable when in independent mode + - Conditionally applies either independent or coupled constraints based on `can_produce_thermal_independently` flag + +### 3. CHP Results: `src/results/chp.jl` + +**Added Result Fields:** + +- `size_thermal_kw`: Thermal capacity size [kW] (only included when `can_produce_thermal_independently=true`) +- `annual_thermal_production_from_source_mmbtu`: Total thermal produced from the source, including thermal consumed for electric production [MMBtu] (only included when `can_produce_thermal_independently=true`) + +**Important Distinction:** +- `annual_thermal_production_mmbtu`: Thermal energy delivered to heating loads (for all CHP modes) +- `annual_thermal_production_from_source_mmbtu`: Total thermal from the source = thermal to loads + thermal consumed to produce electricity (independent mode only) + +**Calculation:** +```julia +annual_thermal_production_from_source_mmbtu = annual_fuel_consumption_mmbtu × thermal_efficiency_full_load +``` + +This matches the physics where the thermal source capacity must serve both direct thermal loads and provide thermal for electric conversion. + +**Modified Function:** + +- `add_chp_results()`: Conditionally adds thermal capacity and total thermal from source to results dictionary + +### 4. Test Files + +**New Scenario:** `test/scenarios/chp_independent_thermal.json` +- Hospital building with electric and thermal loads +- CHP configured with independent thermal production +- High thermal efficiency (0.80) vs. electric efficiency (0.35) + +**New Test Code:** `test/test_temp.jl` (lines 38+) +- Comprehensive test with fuel accounting verification +- Assertions to validate correct operation + +## Usage + +### Input JSON Structure + +```json +{ + "CHP": { + "fuel_cost_per_mmbtu": 10.0, + "can_produce_thermal_independently": true, + + "max_kw": 1000, + "min_kw": 0, + "electric_efficiency_full_load": 0.35, + "electric_efficiency_half_load": 0.35, + + "max_thermal_kw": 5000, + "min_thermal_kw": 0, + "thermal_efficiency_full_load": 0.80, + "thermal_efficiency_half_load": 0.80, + + "installed_cost_per_kw": 2000.0, + "om_cost_per_kw": 100.0, + "om_cost_per_kwh": 0.01, + "min_turn_down_fraction": 0.5 + } +} +``` + +### Results Structure + +When `can_produce_thermal_independently = true`, results include: + +```json +{ + "CHP": { + "size_kw": 1000.0, + "size_thermal_kw": 5000.0, + "annual_electric_production_kwh": 3500000.0, + "annual_thermal_production_mmbtu": 15000.0, + "annual_thermal_production_from_source_mmbtu": 25000.0, + "annual_fuel_consumption_mmbtu": 31250.0, + ... + } +} +``` + +**Result Field Explanations:** + +- `size_kw`: Electric generation equipment capacity (turbine/generator) [kW] +- `size_thermal_kw`: Thermal source capacity (reactor, wellfield) [kW] +- `annual_electric_production_kwh`: Electricity produced [kWh] +- `annual_thermal_production_mmbtu`: Thermal energy delivered to heating loads [MMBtu] +- `annual_thermal_production_from_source_mmbtu`: **Total thermal from source** = thermal to loads + thermal consumed for electric production [MMBtu] +- `annual_fuel_consumption_mmbtu`: Total fuel consumed [MMBtu] + +**Comparing with Boiler + SteamTurbine:** +- CHP `annual_thermal_production_from_source_mmbtu` ≈ Boiler `annual_thermal_production_mmbtu` (both include thermal for electric) +- CHP `annual_thermal_production_mmbtu` ≈ (Boiler thermal to loads) + (thermal to storage) (excludes thermal to steam turbine) + +## Important Notes + +### ⚠️ Cost Limitations + +**Current Implementation:** + +- `installed_cost_per_kw` applies **only to ELECTRIC capacity** +- `om_cost_per_kw` applies **only to ELECTRIC capacity** +- There are NO separate cost parameters for thermal capacity + +**Implications:** + +For technologies where thermal and electric capacities have significantly different costs, the current cost structure may not accurately represent total capital and O&M costs. The optimization will size thermal capacity based on: +- Thermal load requirements +- Fuel costs +- Value of thermal energy +- BUT NOT on incremental thermal capacity costs + +### Prime Mover Defaults + +**Do NOT use `prime_mover` parameter for independent thermal CHP.** The traditional prime_mover-based defaults (reciprocating engine, combustion turbine, fuel cell, micro turbine) are designed for coupled thermal-electric operation and do not apply well to independent thermal systems. + +**Instead, explicitly specify:** +- All efficiency parameters +- All sizing bounds +- All cost parameters + +### Efficiency Definitions + +**Traditional CHP:** +- `electric_efficiency_full_load` = electric output / fuel input at full load +- `thermal_efficiency_full_load` = thermal output / fuel input at full electric load + +**Independent Thermal CHP (Nuclear/Geothermal):** +- `thermal_efficiency_full_load` = thermal source output / fuel (or primary energy) input +- `electric_efficiency_full_load` = electric output / thermal input to power cycle +- These combine: overall fuel→electric efficiency = thermal_eff × electric_eff + +**Example:** +- Nuclear reactor: thermal_eff = 0.33 (fuel→thermal), electric_eff = 0.35 (thermal→electric) +- Overall fuel→electric = 0.33 × 0.35 = 0.116 (11.6%) +- But reactor can deliver thermal at 33% fuel efficiency for direct heating +- This represents: ~1 unit fuel → 0.33 units thermal → (0.116 units electric OR 0.33 units process heat) + +## Future Improvements + +### 1. Separate Thermal Capacity Costs (High Priority) + +Add new parameters: + +```julia +installed_cost_per_kw_thermal::Float64 # Capital cost per kW of thermal capacity [$/kW-thermal] +tech_sizes_for_thermal_cost_curve::Vector{Float64} # Support cost curves for thermal +om_cost_per_kw_thermal::Float64 # Fixed O&M per kW of thermal capacity [$/kW-thermal/year] +om_cost_per_kwh_thermal::Float64 # Variable O&M per kWh of thermal production [$/kWh-thermal] +``` + +**Impact:** Would enable accurate cost representation for technologies where thermal and electric capacities have different cost structures (e.g., nuclear reactor core vs. steam turbine generator vs. process heat exchangers). + +### 2. Enhanced Cost Curves + +Support multi-dimensional cost curves that account for: +- Electric capacity size +- Thermal capacity size +- Ratio of thermal to electric capacity + +### 3. Default Parameter Sets + +Create default parameter libraries for common independent thermal technologies: +- Nuclear (small modular reactors) +- Geothermal (direct use + binary cycle) +- Solar thermal with backup electric +- Industrial waste heat recovery with power generation + +### 4. Thermal Capacity Turn-Down + +Add `min_thermal_turn_down_fraction` parameter separate from electric turn-down, since thermal and electric may have different operational constraints. + +### 5. Separate Unavailability + +Allow independent unavailability schedules for electric vs. thermal systems: +```julia +unavailability_periods_electric::Vector{Dict} +unavailability_periods_thermal::Vector{Dict} +``` +chp_independent_thermal.jl") +``` + +The test includes two sections: + +### 1. Independent Thermal CHP Test +Verifies: +- Thermal capacity is sized separately from electric +- `size_thermal_kw` and `annual_thermal_production_from_source_mmbtu` appear in results +- Fuel accounting is accurate for independent operation +- Physics verification: fuel → thermal source → (loads + electric) + +### 2. Boiler + SteamTurbine Comparison Test +Compares equivalent modeling approaches: +- Independent Thermal CHP vs. Boiler + SteamTurbine +- Verifies similar total thermal production, electric production, and fuel consumption +- Uses `annual_thermal_production_from_source_mmbtu` for apples-to-apples comparison +## Testing + +Run the test with: + +```julia +cd("c:/Users/wbecker/.julia/dev/REopt/test") +include("test_temp.jl") +``` + +The test verifies: +- Thermal capacity is sized separately from electric +- `size_thermal_kw` appears in results +- Fuel accounting is accurate for independent operation +- Thermal production occurs as expected + +## Backward Compatibility + +✅ **Fully backward compatible** + +- Default value: `can_produce_thermal_independently = false` +- Existing CHP models continue to work with coupled thermal-electric operation +- No changes required to existing input files +- Results structure unchanged for traditional CHP (no `size_thermal_kw` field added) + +## Technical Details + +### Decision Variables + +**Traditional Mode:** +- `dvSize[t]` - Electric capacity [kW] + +**Independent Mode:** +- `dvSize[t]` - Electric capacity [kW] +- `dvThermalSize[t]` - Thermal capacity [kW] (NEW) + +### Constraint Logic + +**Fuel Consumption:** + +Traditional: +``` +Fuel = f(Electric_Production, coupled_thermal_efficiency) +``` + +Independent (Corrected Physics): +``` +Total_Thermal_From_Source = Thermal_To_Loads + Electric_Production / Electric_Efficiency +Fuel = Total_Thermal_From_Source / Thermal_Efficiency_Of_Source + Supplementary_Firing +``` + +**Thermal Production:** + +Traditional: +``` +Thermal ≤ g(Electric_Production, thermal_electric_ratio) +``` + +Independent (Properly Coupled): +``` +Thermal_To_Loads + Electric_Production / Electric_Efficiency ≤ Thermal_Source_Capacity +Electric_Production ≤ Electric_Equipment_Capacity +``` + +**Example Scenario:** +- Thermal source capacity = 1000 kW (reactor/wellfield) +- Electric equipment capacity = 300 kW (turbine/generator) +- Electric efficiency = 0.30 (thermal→electric conversion) + +Possible operations: +1. **Max Direct Thermal:** 1000 kW thermal to loads, 0 kW electric +2. **Max Electric:** 300 kW electric (consumes 1000 kW thermal), 0 kW to loads +3. **Balanced:** 150 kW electric (consumes 500 kW thermal), 500 kW thermal to loads +4. **Can't do:** 300 kW electric + 500 kW thermal (would need 1500 kW source capacity) + +### Load Compatibility + +All existing load compatibility constraints remain active: +- `can_serve_dhw` +- `can_serve_space_heating` +- `can_serve_process_heat` +- `can_supply_steam_turbine` + +These apply identically in both traditional and independent modes. + +## Questions or Issues? + +For questions about this implementation, contact the development team or open an issue on the REopt.jl GitHub repository. + +--- + +## Appendix: Independent Thermal CHP vs. Boiler + Steam Turbine + +### When to Use Each Modeling Approach + +Both modeling approaches can represent systems that produce thermal and electric energy, but they have different strengths: + +### Independent Thermal CHP (This Implementation) + +**Use When:** +- Modeling a **single integrated technology** (nuclear reactor, geothermal plant, fuel cell system) +- The physical system has one fuel input and can independently choose thermal vs. electric output +- Direct thermal production is more efficient than electric-then-thermal pathway +- You want **simpler input structure and operational modeling** +- Capital costs are primarily for the core technology (reactor, wellfield, etc.) + +**Advantages:** +1. **Physically Accurate for Integrated Systems** - Matches technologies where thermal and electric come from the same core asset +2. **Simpler Inputs** - One technology definition instead of two +3. **Direct Thermal Efficiency** - Can produce thermal at high efficiency without intermediate electric conversion +4. **Single Investment Decision** - One size optimization, one on/off decision per timestep +5. **Clear Cost Attribution** - All costs tied to one system +6. **Flexible Operation** - Can operate in thermal-only, electric-only, or combined modes + +**Example Technologies:** +- Nuclear reactors (can produce process heat or electricity from same core) +- Geothermal systems (direct use heat + binary cycle electric) +- Large fuel cells (can extract thermal before or after electric generation) +- Advanced industrial CHP where thermal is a primary product, not just waste heat recovery + +**Limitations:** +- Currently only electric capacity costs are modeled (see Future Improvements) +- May not capture nuances of steam systems (pressure, quality) +- Less suitable when thermal and electric systems are physically separable + +### Boiler + Steam Turbine (Existing Capability) + +**Use When:** +- Modeling **two distinct physical assets** (boiler + turbine generator) +- You have or could have a boiler without a steam turbine (separable investment) +- Steam distribution system serves multiple purposes +- You need to model **steam pressure/quality** or **extraction vs. condensing** turbines +- Thermal is the primary need, electric is opportunistic from excess steam + +**Advantages:** +1. **Matches Industrial Reality** - Most industrial CHP is actually boiler + steam turbine +2. **Separate Investment Decisions** - Can optimize adding steam turbine to existing boiler +3. **Steam System Representation** - Better captures steam distribution, pressure drops, quality +4. **Backpressure Turbine Modeling** - Can extract steam at intermediate pressure for process loads +5. **Established Technology Performance** - Well-known efficiency curves and operational characteristics +6. **Flexibility in Steam Allocation** - Steam can serve: loads, storage, turbine, or combinations +7. **Separate Sizing** - Boiler and turbine sized independently based on thermal vs. electric needs + +**Example Technologies:** +- Industrial facilities with existing steam boilers adding cogeneration +- Large campus district energy systems with central plants +- Chemical processing plants with steam networks +- Pulp and paper mills, refineries, food processing facilities + +**Limitations:** +- More complex input structure (two technologies) +- Two separate on/off decisions per timestep (increased computational complexity) +- May not represent technologies where thermal and electric are intrinsically coupled +- Requires understanding of steam turbine types (backpressure vs. condensing vs. extraction) + +### Key Modeling Differences + +| Aspect | Independent Thermal CHP | Boiler + Steam Turbine | +|--------|------------------------|------------------------| +| **Physical Representation** | Single integrated asset | Two separate assets | +| **Fuel Consumption** | Fuel → (Thermal OR Electric OR Both) | Fuel → Boiler → Thermal → (Load OR Turbine → Electric) | +| **Thermal Efficiency** | Direct: Thermal/Fuel | Boiler efficiency only | +| **Electric Efficiency** | Direct: Electric/Fuel | Thermal → Electric conversion | +| **Investment Decision** | One size variable | Two size variables | +| **Operational Complexity** | One binary on/off | Two binary on/offs (if binaries used) | +| **Steam Distribution** | Not explicitly modeled | Can model distribution to multiple endpoints | +| **Cost Structure** | Single CapEx + O&M | Separate CapEx + O&M for each | + +### Hybrid Approach Considerations + +**Can you model the same physical system both ways?** + +Sometimes, yes. For example: + +**Nuclear Plant with Process Heat Extraction:** +- **As Independent Thermal CHP:** Nuclear reactor produces thermal (process heat) or electric or both +- **As Boiler + Steam Turbine:** Nuclear "boiler" produces steam; steam turbine extracts electric; remaining steam serves thermal load + +**The choice depends on:** +1. **What investment decisions do you want to optimize?** + - If sizing reactor core vs. adding turbine capacity → Separate is better + - If sizing integrated system as one unit → Independent is better + +2. **What operational flexibility exists?** + - If steam can bypass turbine entirely → Both work + - If thermal requires electric generation first → Separate is more accurate + +3. **What costs are most significant?** + - If reactor/core is 90% of cost → Independent is simpler + - If turbine is major separate cost → Separate is more accurate + +4. **Input data availability:** + - Steam turbine performance curves available → Separate is easier + - Only know integrated system performance → Independent is easier + +### Recommendation + +For **geothermal, nuclear, or advanced fuel cell systems** where: +- The technology is inherently integrated +- Direct thermal production doesn't require electric generation +- You're sizing the core system as one unit + +→ **Use Independent Thermal CHP** + +For **industrial cogeneration** where: +- You have an existing or separately-sizable boiler +- Steam distribution is important +- You're evaluating adding power generation to steam systems + +→ **Use Boiler + Steam Turbine** + +### Future Enhancement: Unified Framework? + +A potential future enhancement could unify these approaches by: +1. Adding thermal capacity costs to Independent Thermal CHP (already identified as needed) +2. Adding more flexible coupling options (e.g., `thermal_electric_coupling_factor`) +3. Allowing steam turbine to optionally produce thermal independently of incoming steam +4. Creating a generalized "thermal-electric technology" framework that encompasses both + +This would give users maximum modeling flexibility while maintaining physical accuracy. + +--- + +**Implementation Date:** December 28, 2025 +**Branch:** ouu +**Status:** Complete, needs testing with production scenarios diff --git a/src/constraints/chp_constraints.jl b/src/constraints/chp_constraints.jl index 0f25bf54f..803fa417e 100644 --- a/src/constraints/chp_constraints.jl +++ b/src/constraints/chp_constraints.jl @@ -135,6 +135,87 @@ function add_chp_rated_prod_constraint(m, p; _n="") end +""" + add_chp_independent_thermal_production_constraints(m, p; _n="") + +Add constraints for CHP operating in independent thermal production mode, where thermal +production is not coupled to electric production. This mode is suitable for technologies +like nuclear or geothermal that can produce heat independently. + +Key physics representation: +- dvThermalSize represents the total thermal source capacity (reactor power, wellfield output) +- Thermal from source can go to: (1) direct heating loads, or (2) power cycle for electricity +- Electric production consumes thermal capacity via power cycle conversion +- electric_efficiency represents thermal→electric conversion efficiency + +Constraints: +- Total thermal utilization (direct heating + thermal for electric) ≤ thermal source capacity +- Electric production ≤ electric generation equipment capacity +- Fuel consumption = total thermal from source / source thermal efficiency +""" +function add_chp_independent_thermal_production_constraints(m, p; _n="") + # Constraint: Total thermal usage (direct + for electric production) limited by thermal source capacity + # For nuclear/geothermal: thermal_to_loads + thermal_for_electric <= thermal_source_capacity + # where: thermal_for_electric = electric_production / electric_efficiency (thermal→electric conversion) + # Note: dvRatedProduction is the dispatched/rated power, production_factor gives actual production + # IMPORTANT: production_factor scales actual production, NOT capacity limits + @constraint(m, CHPIndependentThermalSourceCon[t in p.techs.chp, ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) + + p.production_factor[t,ts] * m[Symbol("dvRatedProduction"*_n)][t,ts] / p.s.chp.electric_efficiency_full_load <= + m[Symbol("dvThermalSize"*_n)][t] + ) + + # Constraint: Thermal capacity bounds + @constraint(m, CHPThermalCapacityMin[t in p.techs.chp], + m[Symbol("dvThermalSize"*_n)][t] >= p.s.chp.min_thermal_kw + ) + + @constraint(m, CHPThermalCapacityMax[t in p.techs.chp], + m[Symbol("dvThermalSize"*_n)][t] <= p.s.chp.max_thermal_kw + ) +end + + +""" + add_chp_independent_fuel_burn_constraints(m, p; _n="") + +Add fuel consumption constraints for CHP operating in independent thermal mode. + +Key physics for nuclear/geothermal systems: +- Fuel (or primary energy) produces thermal at the source +- Total thermal from source = thermal to loads + thermal converted to electric +- Fuel consumption = total_thermal_from_source / thermal_efficiency_of_source + +This differs from traditional CHP where: +- Fuel goes to prime mover producing electric +- Waste heat recovery produces thermal as byproduct +""" +function add_chp_independent_fuel_burn_constraints(m, p; _n="") + + # Fuel cost expression + 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) + ) + + # For independent thermal systems (nuclear/geothermal): + # - thermal_efficiency_full_load = thermal_source_output / fuel_input + # - Total thermal from source = direct_thermal + thermal_for_electric_conversion + # - Fuel = (direct_thermal + thermal_for_electric) / thermal_efficiency + # Note: dvRatedProduction is dispatched power, production_factor gives actual production + + @constraint(m, CHPIndependentFuelBurnCon[t in p.techs.chp, ts in p.time_steps], + m[Symbol("dvFuelUsage"*_n)][t,ts] == p.hours_per_time_step * ( + # Direct thermal to loads + sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) / p.s.chp.thermal_efficiency_full_load + + # Thermal consumed for electric production (electric / electric_eff = thermal needed) + (p.production_factor[t,ts] * m[Symbol("dvRatedProduction"*_n)][t,ts] / p.s.chp.electric_efficiency_full_load) / p.s.chp.thermal_efficiency_full_load + + # Supplementary firing (if applicable) + m[Symbol("dvSupplementaryThermalProduction"*_n)][t,ts] / p.s.chp.supplementary_firing_efficiency + ) + ) +end + + """ add_chp_hourly_om_charges(m, p; _n="") @@ -182,6 +263,12 @@ function add_chp_constraints(m, p; _n="") binCHPIsOnInTS[p.techs.chp, p.time_steps], Bin # 1 If technology t is operating in time step; 0 otherwise end + # Add thermal sizing decision variable if operating in independent thermal mode + if p.s.chp.can_produce_thermal_independently + dv = "dvThermalSize"*_n + m[Symbol(dv)] = @variable(m, [p.techs.chp], base_name=dv, lower_bound=0) + end + m[:TotalHourlyCHPOMCosts] = 0 m[:TotalCHPFuelCosts] = 0 m[:TotalCHPPerUnitProdOMCosts] = @expression(m, p.third_party_factor * p.pwf_om * @@ -193,8 +280,17 @@ function add_chp_constraints(m, p; _n="") add_chp_hourly_om_charges(m, p) end - add_chp_fuel_burn_constraints(m, p; _n=_n) - add_chp_thermal_production_constraints(m, p; _n=_n) + # Apply constraints based on operating mode + if p.s.chp.can_produce_thermal_independently + # Independent thermal production mode + add_chp_independent_fuel_burn_constraints(m, p; _n=_n) + add_chp_independent_thermal_production_constraints(m, p; _n=_n) + else + # Traditional coupled thermal-electric production mode + add_chp_fuel_burn_constraints(m, p; _n=_n) + add_chp_thermal_production_constraints(m, p; _n=_n) + end + add_binCHPIsOnInTS_constraints(m, p; _n=_n) add_chp_rated_prod_constraint(m, p; _n=_n) diff --git a/src/core/chp.jl b/src/core/chp.jl index c59514a9f..6fb0c27ba 100644 --- a/src/core/chp.jl +++ b/src/core/chp.jl @@ -40,6 +40,9 @@ conflict_res_min_allowable_fraction_of_max = 0.25 can_serve_space_heating::Bool = true # If CHP can supply heat to the space heating load can_serve_process_heat::Bool = true # If CHP can supply heat to the process heating load is_electric_only::Bool = false # If CHP is a prime generator that does not supply heat + can_produce_thermal_independently::Bool = false # If CHP can produce thermal energy independently of electric production (e.g., nuclear, geothermal) + min_thermal_kw::Float64 = 0.0 # Minimum thermal capacity constraint for optimization (only used when can_produce_thermal_independently=true) + max_thermal_kw::Float64 = NaN # Maximum thermal capacity constraint for optimization (only used when can_produce_thermal_independently=true) macrs_option_years::Int = 5 # Notes: this value cannot be 0 if aiming to apply 100% bonus depreciation; default may change if Site.sector is not "commercial/industrial" macrs_bonus_fraction::Float64 = 1.0 #Note: default may change if Site.sector is not "commercial/industrial" @@ -113,6 +116,9 @@ Base.@kwdef mutable struct CHP <: AbstractCHP can_serve_space_heating::Bool = true can_serve_process_heat::Bool = true is_electric_only::Bool = false + can_produce_thermal_independently::Bool = false + min_thermal_kw::Float64 = 0.0 + max_thermal_kw::Float64 = NaN macrs_option_years::Int = 5 macrs_bonus_fraction::Float64 = 1.0 diff --git a/src/results/chp.jl b/src/results/chp.jl index 58f302804..9b2d7d237 100644 --- a/src/results/chp.jl +++ b/src/results/chp.jl @@ -2,10 +2,12 @@ """ `CHP` results keys: - `size_kw` Power capacity size of the CHP system [kW] +- `size_thermal_kw` Thermal capacity size of the CHP system [kW] (only when can_produce_thermal_independently=true) - `size_supplemental_firing_kw` Power capacity of CHP supplementary firing system [kW] - `annual_fuel_consumption_mmbtu` Fuel consumed in a year [MMBtu] - `annual_electric_production_kwh` Electric energy produced in a year [kWh] - `annual_thermal_production_mmbtu` Thermal energy produced in a year (not including curtailed thermal) [MMBtu] +- `annual_thermal_production_from_source_mmbtu` Total thermal from source including thermal for electric conversion (only when can_produce_thermal_independently=true) [MMBtu] - `electric_production_series_kw` Electric power production time-series array [kW] - `electric_to_grid_series_kw` Electric power exported time-series array [kW] - `electric_to_storage_series_kw` Electric power to charge the battery storage time-series array [kW] @@ -33,6 +35,12 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") # Note: the node number is an empty string if evaluating a single `Site`. r = Dict{String, Any}() r["size_kw"] = value(sum(m[Symbol("dvSize"*_n)][t] for t in p.techs.chp)) + + # Add thermal capacity size if operating in independent thermal production mode + if p.s.chp.can_produce_thermal_independently + r["size_thermal_kw"] = value(sum(m[Symbol("dvThermalSize"*_n)][t] for t in p.techs.chp)) + end + 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) @@ -49,6 +57,18 @@ function add_chp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r["annual_thermal_production_mmbtu"] = round(p.hours_per_time_step * sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) + # For independent thermal mode, also report total thermal from source (includes thermal consumed for electric production) + if p.s.chp.can_produce_thermal_independently + # Total thermal from source = fuel consumption × thermal_efficiency + # This matches the physics: thermal_source_output = thermal_to_loads + thermal_for_electric + r["annual_thermal_production_from_source_mmbtu"] = round( + r["annual_fuel_consumption_mmbtu"] * p.s.chp.thermal_efficiency_full_load, + digits=3 + ) + else + r["annual_thermal_production_from_source_mmbtu"] = 0.0 + end + @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) diff --git a/test/scenarios/boiler_steamturbine_comparison.json b/test/scenarios/boiler_steamturbine_comparison.json new file mode 100644 index 000000000..24a704522 --- /dev/null +++ b/test/scenarios/boiler_steamturbine_comparison.json @@ -0,0 +1,43 @@ +{ + "Site": { + "latitude": 37.78, + "longitude": -122.45 + }, + "ElectricLoad": { + "doe_reference_name": "Hospital" + }, + "ElectricTariff": { + "blended_annual_energy_rate": 0.12, + "blended_annual_demand_rate": 10.0 + }, + "SpaceHeatingLoad": { + "doe_reference_name": "Hospital" + }, + "DomesticHotWaterLoad": { + "doe_reference_name": "Hospital" + }, + "ExistingBoiler": { + "fuel_cost_per_mmbtu": 20.0 + }, + "Boiler": { + "fuel_cost_per_mmbtu": 5.0, + "efficiency": 0.80, + "max_mmbtu_per_hour": 17.065, + "installed_cost_per_mmbtu_per_hour": 0.0, + "om_cost_per_mmbtu": 0.0 + }, + "SteamTurbine": { + "electric_produced_to_thermal_consumed_ratio": 0.35, + "thermal_produced_to_thermal_consumed_ratio": 0.0, + "max_kw": 1000, + "min_kw": 0, + "installed_cost_per_kw": 2000.0, + "om_cost_per_kwh": 0.01, + "can_serve_dhw": true, + "can_serve_space_heating": true, + "can_serve_process_heat": true, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0, + "federal_itc_fraction": 0.0 + } +} diff --git a/test/scenarios/chp_independent_thermal.json b/test/scenarios/chp_independent_thermal.json new file mode 100644 index 000000000..2a510061f --- /dev/null +++ b/test/scenarios/chp_independent_thermal.json @@ -0,0 +1,50 @@ +{ + "Site": { + "latitude": 37.78, + "longitude": -122.45 + }, + "ElectricLoad": { + "doe_reference_name": "Hospital" + }, + "ElectricTariff": { + "blended_annual_energy_rate": 0.12, + "blended_annual_demand_rate": 10.0 + }, + "SpaceHeatingLoad": { + "doe_reference_name": "Hospital" + }, + "DomesticHotWaterLoad": { + "doe_reference_name": "Hospital" + }, + "ExistingBoiler": { + "fuel_cost_per_mmbtu": 20.0 + }, + "CHP": { + "fuel_cost_per_mmbtu": 5.0, + "min_kw": 0, + "max_kw": 1000, + "installed_cost_per_kw": 2000.0, + "om_cost_per_kwh": 0.01, + "electric_efficiency_full_load": 0.35, + "electric_efficiency_half_load": 0.35, + "min_turn_down_fraction": 0.0, + "thermal_efficiency_full_load": 0.80, + "thermal_efficiency_half_load": 0.80, + "can_produce_thermal_independently": true, + "min_thermal_kw": 0, + "max_thermal_kw": 20000, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0, + "macrs_itc_reduction": 0.0, + "federal_itc_fraction": 0.0, + "unavailability_periods": [ + { + "month": 1, + "start_week_of_month": 2, + "start_day_of_week": 6, + "start_hour": 1, + "duration_hours": 0 + } + ] + } +} diff --git a/test/test_chp_independent_thermal.jl b/test/test_chp_independent_thermal.jl new file mode 100644 index 000000000..de74a02fe --- /dev/null +++ b/test/test_chp_independent_thermal.jl @@ -0,0 +1,129 @@ +using Revise +using JSON +using Test +using JuMP +using HiGHS +using REopt + +############### Independent Thermal CHP Testing ################ +println("\n========== Testing Independent Thermal CHP ==========") +d = JSON.parsefile("./scenarios/chp_independent_thermal.json") +s = Scenario(d) +p = REoptInputs(s) + +println("CHP fuel cost: ", p.s.chp.fuel_cost_per_mmbtu) +println("Boiler fuel cost: ", p.s.existing_boiler.fuel_cost_per_mmbtu) +println("CHP thermal efficiency: ", p.s.chp.thermal_efficiency_full_load) +println("CHP electric (thermal-to-elec, not fuel-to-elec) efficiency: ", p.s.chp.electric_efficiency_full_load) +println("CHP max thermal capacity: ", p.s.chp.max_thermal_kw, " kW") +println("CHP max electric capacity: ", p.s.chp.max_kw, " kW") + +m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.005)) +results = run_reopt(m1, p) + +# Extract key results +chp_elec_size = results["CHP"]["size_kw"] +chp_thermal_size = results["CHP"]["size_thermal_kw"] +chp_annual_electric_production = results["CHP"]["annual_electric_production_kwh"] +chp_annual_thermal_production = results["CHP"]["annual_thermal_production_mmbtu"] +chp_annual_thermal_from_source = results["CHP"]["annual_thermal_production_from_source_mmbtu"] +chp_annual_fuel_consumption = results["CHP"]["annual_fuel_consumption_mmbtu"] + +# Display results +println("\n=== Optimization Results ===") +println("CHP Electric Capacity: ", round(chp_elec_size, digits=2), " kW") +println("CHP Thermal Capacity: ", round(chp_thermal_size, digits=2), " kW") +println("Annual Electric Production: ", round(chp_annual_electric_production, digits=2), " kWh") +println("Annual Thermal to Loads: ", round(chp_annual_thermal_production, digits=2), " MMBtu") +println("Annual Thermal from Source (total): ", round(chp_annual_thermal_from_source, digits=2), " MMBtu") +println("Annual Fuel Consumption: ", round(chp_annual_fuel_consumption, digits=2), " MMBtu") + +# Check if boiler is being used instead +if haskey(results, "ExistingBoiler") + boiler_thermal = results["ExistingBoiler"]["annual_thermal_production_mmbtu"] + println("Existing Boiler Thermal Production: ", round(boiler_thermal, digits=2), " MMBtu") +end + +# Check time-series to see if any thermal is produced in any timestep +println("\nTime-Series Analysis:") +println("Max thermal production in any hour: ", round(maximum(results["CHP"]["thermal_production_series_mmbtu_per_hour"]), digits=3), " MMBtu/hr") +println("Number of hours with thermal production > 0: ", sum(results["CHP"]["thermal_production_series_mmbtu_per_hour"] .> 0.001)) +println("Max electric production in any hour: ", round(maximum(results["CHP"]["electric_production_series_kw"]), digits=3), " kW") + +# Calculate efficiencies to verify coupled thermal-electric physics +# For nuclear/geothermal: total thermal from source = thermal_to_loads + thermal_for_electric +# where thermal_for_electric = electric_production / electric_efficiency +thermal_for_electric_mmbtu = chp_annual_electric_production * REopt.KWH_PER_MMBTU^-1 / p.s.chp.electric_efficiency_full_load +total_thermal_from_source_mmbtu = chp_annual_thermal_production + thermal_for_electric_mmbtu +estimated_fuel_consumption_mmbtu = total_thermal_from_source_mmbtu / p.s.chp.thermal_efficiency_full_load + +println("\nPhysics Verification (Nuclear/Geothermal Model):") +println("Thermal to loads directly: ", round(chp_annual_thermal_production, digits=2), " MMBtu") +println("Thermal consumed for electric: ", round(thermal_for_electric_mmbtu, digits=2), " MMBtu") +println("Total thermal from source: ", round(total_thermal_from_source_mmbtu, digits=2), " MMBtu") +println("Estimated fuel consumption: ", round(estimated_fuel_consumption_mmbtu, digits=2), " MMBtu") +println("Actual fuel consumption: ", round(chp_annual_fuel_consumption, digits=2), " MMBtu") +println("Difference: ", round(abs(estimated_fuel_consumption_mmbtu - chp_annual_fuel_consumption), digits=2), " MMBtu") +println("Overall fuel→electric efficiency: ", round(p.s.chp.thermal_efficiency_full_load * p.s.chp.electric_efficiency_full_load, digits=4)) + +# Test assertions +@test chp_thermal_size > 0 # Should have thermal capacity +@test haskey(results["CHP"], "size_thermal_kw") # Key should exist +@test chp_annual_thermal_production > 0 # Should produce thermal energy +@test abs(estimated_fuel_consumption_mmbtu - chp_annual_fuel_consumption) / chp_annual_fuel_consumption < 0.05 # Fuel calculation should be within 5% + +println("\n✓ Independent Thermal CHP test completed successfully") +println("======================================================\n") + + +############### Boiler + SteamTurbine Comparison Test ################ +println("\n========== Testing Boiler + SteamTurbine (Comparison) ==========") +d_boiler = JSON.parsefile("./scenarios/boiler_steamturbine_comparison.json") +s_boiler = Scenario(d_boiler) +p_boiler = REoptInputs(s_boiler) + +println("Boiler fuel cost: ", p_boiler.s.boiler.fuel_cost_per_mmbtu) +println("Boiler efficiency: ", p_boiler.boiler_efficiency["Boiler"]) +println("SteamTurbine electric_produced_to_thermal_consumed_ratio: ", p_boiler.s.steam_turbine.electric_produced_to_thermal_consumed_ratio) +println("Overall fuel→electric efficiency (Boiler × ST): ", round(p_boiler.boiler_efficiency["Boiler"] * p_boiler.s.steam_turbine.electric_produced_to_thermal_consumed_ratio, digits=4)) +println("SteamTurbine max electric capacity: ", p_boiler.s.steam_turbine.max_kw, " kW") +println("\nComparison with CHP:") +println("CHP fuel→electric efficiency: ", round(p.s.chp.thermal_efficiency_full_load * p.s.chp.electric_efficiency_full_load, digits=4)) +println("Should match: Boiler.eff × ST.elec_ratio = ", round(p_boiler.boiler_efficiency["Boiler"] * p_boiler.s.steam_turbine.electric_produced_to_thermal_consumed_ratio, digits=4)) + +m_boiler = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.005)) +results_boiler = run_reopt(m_boiler, p_boiler) + +# Extract key results +boiler_size = results_boiler["Boiler"]["size_mmbtu_per_hour"] +steamturbine_size = results_boiler["SteamTurbine"]["size_kw"] +boiler_annual_thermal = results_boiler["Boiler"]["annual_thermal_production_mmbtu"] +steamturbine_annual_electric = results_boiler["SteamTurbine"]["annual_electric_production_kwh"] +boiler_annual_fuel = results_boiler["Boiler"]["annual_fuel_consumption_mmbtu"] + +println("\n=== Boiler + SteamTurbine Results ===") +println("Boiler Thermal Capacity: ", round(boiler_size, digits=2), " MMBtu/hr (", round(boiler_size * REopt.KWH_PER_MMBTU, digits=2), " kW)") +println("SteamTurbine Electric Capacity: ", round(steamturbine_size, digits=2), " kW") +println("Annual Thermal Production: ", round(boiler_annual_thermal, digits=2), " MMBtu") +println("Annual Electric Production: ", round(steamturbine_annual_electric, digits=2), " kWh") +println("Annual Fuel Consumption: ", round(boiler_annual_fuel, digits=2), " MMBtu") + +# Compare with CHP results +println("\n=== Comparison: CHP vs Boiler+SteamTurbine ===") +println("Total Thermal from Source - CHP: ", round(chp_annual_thermal_from_source, digits=2), " MMBtu | Boiler: ", round(boiler_annual_thermal, digits=2), " MMBtu") +println(" (CHP breakdown: ", round(chp_annual_thermal_production, digits=2), " to loads + ", round(chp_annual_thermal_from_source - chp_annual_thermal_production, digits=2), " for electric)") +println("Electric Production - CHP: ", round(chp_annual_electric_production, digits=2), " kWh | SteamTurbine: ", round(steamturbine_annual_electric, digits=2), " kWh") +println("Fuel Consumption - CHP: ", round(chp_annual_fuel_consumption, digits=2), " MMBtu | Boiler: ", round(boiler_annual_fuel, digits=2), " MMBtu") + +# Calculate differences (using total thermal from source for apples-to-apples comparison) +thermal_diff_pct = abs(chp_annual_thermal_from_source - boiler_annual_thermal) / max(chp_annual_thermal_from_source, boiler_annual_thermal) * 100 +electric_diff_pct = abs(chp_annual_electric_production - steamturbine_annual_electric) / max(chp_annual_electric_production, steamturbine_annual_electric) * 100 +fuel_diff_pct = abs(chp_annual_fuel_consumption - boiler_annual_fuel) / max(chp_annual_fuel_consumption, boiler_annual_fuel) * 100 + +println("\nDifferences (%):") +println("Total Thermal from Source: ", round(thermal_diff_pct, digits=2), "%") +println("Electric: ", round(electric_diff_pct, digits=2), "%") +println("Fuel: ", round(fuel_diff_pct, digits=2), "%") + +println("\n✓ Boiler + SteamTurbine comparison test completed") +println("======================================================\n")