feat: implement 1D regenerator lumped thermal mass model and reversing cycle logic
All checks were successful
Deploy Documentation / build-and-deploy (push) Successful in 1m31s
All checks were successful
Deploy Documentation / build-and-deploy (push) Successful in 1m31s
This commit is contained in:
parent
43499f58e3
commit
d38e5218d6
3 changed files with 243 additions and 12 deletions
255
Battery.py
255
Battery.py
|
|
@ -140,6 +140,62 @@ class CombustionChamber:
|
||||||
return self.hA * (Tgas - self.Twall0), self.hA * (Tgas - self.Twall1)
|
return self.hA * (Tgas - self.Twall0), self.hA * (Tgas - self.Twall1)
|
||||||
|
|
||||||
|
|
||||||
|
class Regenerator:
|
||||||
|
"""Models a single regenerator chamber in the coke oven battery.
|
||||||
|
|
||||||
|
Uses a 1D lumped thermal mass model to track temperature dynamics during
|
||||||
|
heat storage (charging) and release (discharging) cycles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, idx, T_init=1016.9, M_reg=1e9, Cp_reg=1000.0, eff=0.8):
|
||||||
|
"""Initializes the Regenerator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
idx (int): Index identifier of the regenerator.
|
||||||
|
T_init (float, optional): Initial brick temperature (K). Default is 1016.9 K
|
||||||
|
to replicate the 600°C boundary condition.
|
||||||
|
M_reg (float, optional): Mass of checker bricks (kg). Default is 1e9
|
||||||
|
(option A: infinite thermal mass to suppress dynamics for verification).
|
||||||
|
Cp_reg (float, optional): Specific heat of checker bricks (J/kg/K). Default 1000.
|
||||||
|
eff (float, optional): Heat exchange effectiveness (0 to 1). Default 0.8.
|
||||||
|
"""
|
||||||
|
self.idx = idx
|
||||||
|
self.T = T_init
|
||||||
|
self.M_reg = M_reg
|
||||||
|
self.Cp_reg = Cp_reg
|
||||||
|
self.eff = eff
|
||||||
|
self.Cp_gas = 1000.0 # J/kg/K, average heat capacity of gas mixture
|
||||||
|
|
||||||
|
def heat_exchange(self, dt_sec, mdot, T_gas_in, is_discharging):
|
||||||
|
"""Calculates gas outlet temperature and updates regenerator brick temperature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt_sec (float): Time step in seconds.
|
||||||
|
mdot (float): Mass flow rate of air/flue gas (kg/s).
|
||||||
|
T_gas_in (float): Temperature of incoming gas (K).
|
||||||
|
is_discharging (bool): True if air/gas is being preheated (discharging),
|
||||||
|
False if hot flue gas is reheating the bricks (charging).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Temperature of the outgoing gas (K).
|
||||||
|
"""
|
||||||
|
if mdot <= 1e-8:
|
||||||
|
return T_gas_in
|
||||||
|
|
||||||
|
if is_discharging:
|
||||||
|
# Heat release cycle: incoming cold air at T_gas_in is preheated to T_gas_out
|
||||||
|
T_gas_out = T_gas_in + self.eff * (self.T - T_gas_in)
|
||||||
|
Q_dot = mdot * self.Cp_gas * (T_gas_out - T_gas_in) # W (J/s)
|
||||||
|
self.T -= (Q_dot * dt_sec) / (self.M_reg * self.Cp_reg)
|
||||||
|
return T_gas_out
|
||||||
|
else:
|
||||||
|
# Heat storage cycle: incoming hot flue gas at T_gas_in heats the bricks
|
||||||
|
T_gas_out = T_gas_in - self.eff * (T_gas_in - self.T)
|
||||||
|
Q_dot = mdot * self.Cp_gas * (T_gas_in - T_gas_out) # W (J/s)
|
||||||
|
self.T += (Q_dot * dt_sec) / (self.M_reg * self.Cp_reg)
|
||||||
|
return T_gas_out
|
||||||
|
|
||||||
|
|
||||||
class CokeCharge:
|
class CokeCharge:
|
||||||
"""Represents a single coal/coke charge inside an oven.
|
"""Represents a single coal/coke charge inside an oven.
|
||||||
|
|
||||||
|
|
@ -627,6 +683,28 @@ class Battery:
|
||||||
for ioven in range(self.size)
|
for ioven in range(self.size)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Regenerators and fuel valves initialization (size + 2 elements)
|
||||||
|
# Option A is default: M_reg=1e9 to replicate the legacy 600°C boundary condition.
|
||||||
|
self.regenerators = [
|
||||||
|
Regenerator(ireg, T_init=1016.9, M_reg=1e9, Cp_reg=1000.0, eff=0.8)
|
||||||
|
for ireg in range(self.size + 2)
|
||||||
|
]
|
||||||
|
self.fuel_valves = np.zeros(self.size + 2)
|
||||||
|
self.reversing_period = 20.0 / 60.0 # 20 minutes in hours
|
||||||
|
self.control_active = False
|
||||||
|
|
||||||
|
# Cantera parameters for dynamic preheating and equilibrium calculations
|
||||||
|
self.fuel = "H2:6.42, O2:0.39, N2:47.28, CH4:1.79, CO:24.25, CO2:19.72, C2H4:0.13, C2H6:0.04"
|
||||||
|
self.oxidizer = "O2:0.21,N2:0.79"
|
||||||
|
try:
|
||||||
|
f_found = optimize.root_scalar(
|
||||||
|
lambda x: coke_oven_exhaust_stoichiometry(x)["O2"] - 0.045,
|
||||||
|
bracket=[1e-300, 1]
|
||||||
|
)
|
||||||
|
self.phi = f_found.root
|
||||||
|
except Exception:
|
||||||
|
self.phi = 0.814238515
|
||||||
|
|
||||||
# For 1~4 Coke Ovens with n+5 P/C sequence
|
# For 1~4 Coke Ovens with n+5 P/C sequence
|
||||||
start_indices = [1, 3, 5, 2, 4]
|
start_indices = [1, 3, 5, 2, 4]
|
||||||
self.oven_idx_order = np.concatenate(
|
self.oven_idx_order = np.concatenate(
|
||||||
|
|
@ -647,9 +725,14 @@ class Battery:
|
||||||
for chmbr, T1 in zip(self.chambers, latest_chamber[1]):
|
for chmbr, T1 in zip(self.chambers, latest_chamber[1]):
|
||||||
chmbr.T1 = T1
|
chmbr.T1 = T1
|
||||||
|
|
||||||
# Recover Wall State
|
# Recover Wall State with copy() to break reference linkage with history lists
|
||||||
for wl, wu, wallT in zip(self.walls_0, self.walls_1, latest_wall[1]):
|
for wl, wu, wallT in zip(self.walls_0, self.walls_1, latest_wall[1]):
|
||||||
wl.T_chamber, wl.T_internal.data, wl.T_oven, wu.T_oven, wu.T_internal.data, wu.T_chamber = wallT
|
wl.T_chamber = wallT[0]
|
||||||
|
wl.T_internal.data = wallT[1].copy()
|
||||||
|
wl.T_oven = wallT[2]
|
||||||
|
wu.T_oven = wallT[3]
|
||||||
|
wu.T_internal.data = wallT[4].copy()
|
||||||
|
wu.T_chamber = wallT[5]
|
||||||
|
|
||||||
# Recover Oven State
|
# Recover Oven State
|
||||||
for coal in self.processing:
|
for coal in self.processing:
|
||||||
|
|
@ -684,6 +767,95 @@ class Battery:
|
||||||
"""
|
"""
|
||||||
return self.mdot0 * self.heat_program.f(t) / self.normal_heat
|
return self.mdot0 * self.heat_program.f(t) / self.normal_heat
|
||||||
|
|
||||||
|
def is_cycle_A(self, t):
|
||||||
|
"""Checks if the battery is currently in Cycle A (odd-numbered regenerators discharging).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
t (float): Simulation time (hours).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if in Cycle A, False if in Cycle B.
|
||||||
|
"""
|
||||||
|
cycle_num = int(np.floor(t / self.reversing_period))
|
||||||
|
return (cycle_num % 2) == 0
|
||||||
|
|
||||||
|
def get_chamber_inlets(self, t):
|
||||||
|
"""Computes mass flow rate and maps regenerators for each combustion chamber.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
t (float): Current simulation time (hours).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (chamber_mdots, inlet_reg_indices, outlet_reg_indices)
|
||||||
|
- chamber_mdots (np.ndarray): Fuel-air flow rate for each chamber (kg/s).
|
||||||
|
- inlet_reg_indices (list): Regenerator index supplying to each chamber.
|
||||||
|
- outlet_reg_indices (list): Regenerator index receiving exhaust from each chamber.
|
||||||
|
"""
|
||||||
|
total_mdot = self.mdot(t)
|
||||||
|
size = self.size
|
||||||
|
|
||||||
|
# If external control is inactive, dynamically reset valves to legacy distribution
|
||||||
|
if not getattr(self, "control_active", False):
|
||||||
|
self.fuel_valves = np.zeros(size + 2)
|
||||||
|
if self.is_cycle_A(t):
|
||||||
|
# Cycle A: Odd regenerators (0-indexed even j) discharge
|
||||||
|
self.fuel_valves[0] = total_mdot / size
|
||||||
|
self.fuel_valves[2:size+1:2] = 2.0 * total_mdot / size
|
||||||
|
else:
|
||||||
|
# Cycle B: Even regenerators (0-indexed odd j) discharge
|
||||||
|
self.fuel_valves[1:size+1:2] = 2.0 * total_mdot / size
|
||||||
|
self.fuel_valves[size+1] = total_mdot / size
|
||||||
|
|
||||||
|
# Distribute valve flows to individual combustion chambers
|
||||||
|
chamber_mdots = np.zeros(size + 1)
|
||||||
|
inlet_reg_indices = [-1] * (size + 1)
|
||||||
|
outlet_reg_indices = [-1] * (size + 1)
|
||||||
|
|
||||||
|
is_a = self.is_cycle_A(t)
|
||||||
|
|
||||||
|
if is_a:
|
||||||
|
# Cycle A: Even regenerators (j = 0, 2, ..., size) are INLETS
|
||||||
|
# Odd regenerators (j = 1, 3, ..., size+1) are OUTLETS
|
||||||
|
for j in range(0, size + 1, 2):
|
||||||
|
val = self.fuel_valves[j]
|
||||||
|
if j == 0:
|
||||||
|
chamber_mdots[0] += val
|
||||||
|
inlet_reg_indices[0] = 0
|
||||||
|
else:
|
||||||
|
chamber_mdots[j-1] += val / 2.0
|
||||||
|
chamber_mdots[j] += val / 2.0
|
||||||
|
inlet_reg_indices[j-1] = j
|
||||||
|
inlet_reg_indices[j] = j
|
||||||
|
|
||||||
|
for j in range(1, size + 2, 2):
|
||||||
|
if j == size + 1:
|
||||||
|
outlet_reg_indices[size] = size + 1
|
||||||
|
else:
|
||||||
|
outlet_reg_indices[j-1] = j
|
||||||
|
outlet_reg_indices[j] = j
|
||||||
|
else:
|
||||||
|
# Cycle B: Odd regenerators (j = 1, 3, ..., size+1) are INLETS
|
||||||
|
# Even regenerators (j = 0, 2, ..., size) are OUTLETS
|
||||||
|
for j in range(1, size + 2, 2):
|
||||||
|
val = self.fuel_valves[j]
|
||||||
|
if j == size + 1:
|
||||||
|
chamber_mdots[size] += val
|
||||||
|
inlet_reg_indices[size] = size + 1
|
||||||
|
else:
|
||||||
|
chamber_mdots[j-1] += val / 2.0
|
||||||
|
chamber_mdots[j] += val / 2.0
|
||||||
|
inlet_reg_indices[j-1] = j
|
||||||
|
inlet_reg_indices[j] = j
|
||||||
|
|
||||||
|
for j in range(0, size + 1, 2):
|
||||||
|
if j == 0:
|
||||||
|
outlet_reg_indices[0] = 0
|
||||||
|
else:
|
||||||
|
outlet_reg_indices[j-1] = j
|
||||||
|
outlet_reg_indices[j] = j
|
||||||
|
|
||||||
|
return chamber_mdots, inlet_reg_indices, outlet_reg_indices
|
||||||
|
|
||||||
def next_oven(self):
|
def next_oven(self):
|
||||||
"""Returns the index of the next oven to be pushed and charged.
|
"""Returns the index of the next oven to be pushed and charged.
|
||||||
|
|
||||||
|
|
@ -700,14 +872,36 @@ class Battery:
|
||||||
Args:
|
Args:
|
||||||
dt (float): Simulation time step (hours).
|
dt (float): Simulation time step (hours).
|
||||||
"""
|
"""
|
||||||
# update combustion chamber equilibrium temperature
|
dt_sec = dt * 3600.0
|
||||||
# Tad = 연료 조성과 공연비로 결정
|
size = self.size
|
||||||
# m_dot = 연료 발열량과 공급열량 공연비로 결정
|
is_a = self.is_cycle_A(self.t)
|
||||||
# m(h1 - h0) = hA(Tgas - Twall) => solve with initial T0 = Tad
|
|
||||||
|
|
||||||
# Loop all combustion chambers
|
# Get dynamic mass flows and regenerator mappings for this step
|
||||||
# update chamber wall temperatures and mass flow rates
|
chamber_mdots, inlet_reg_indices, outlet_reg_indices = self.get_chamber_inlets(self.t)
|
||||||
# solve for equilibrium heat to walls
|
|
||||||
|
# 1. Aggregate flow rates for the discharging (inlet) regenerators
|
||||||
|
inlet_mdots = np.zeros(size + 2)
|
||||||
|
for i_chamber, mdot in enumerate(chamber_mdots):
|
||||||
|
inlet_reg_idx = inlet_reg_indices[i_chamber]
|
||||||
|
inlet_mdots[inlet_reg_idx] += mdot
|
||||||
|
|
||||||
|
# 2. Perform heat exchange for discharging regenerators once to find preheat temperatures
|
||||||
|
reg_preheat_temps = np.zeros(size + 2)
|
||||||
|
active_inlets = range(0, size + 1, 2) if is_a else range(1, size + 2, 2)
|
||||||
|
for j in active_inlets:
|
||||||
|
reg_preheat_temps[j] = self.regenerators[j].heat_exchange(
|
||||||
|
dt_sec, inlet_mdots[j], 298.15, is_discharging=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache for Cantera HP equilibrium calculation to save CPU overhead.
|
||||||
|
# Maps inlet_reg_idx -> (T0, burned_state, h0)
|
||||||
|
cantera_cache = {}
|
||||||
|
|
||||||
|
# Arrays to aggregate exhaust properties for the charging (outlet) regenerators
|
||||||
|
outlet_mdots = np.zeros(size + 2)
|
||||||
|
outlet_T_weighted = np.zeros(size + 2)
|
||||||
|
|
||||||
|
# 3. Loop all combustion chambers to solve thermal equations
|
||||||
for i_chamber, chmbr in enumerate(self.chambers):
|
for i_chamber, chmbr in enumerate(self.chambers):
|
||||||
if i_chamber > 0:
|
if i_chamber > 0:
|
||||||
wall_lower = self.walls_1[i_chamber-1]
|
wall_lower = self.walls_1[i_chamber-1]
|
||||||
|
|
@ -719,7 +913,29 @@ class Battery:
|
||||||
else:
|
else:
|
||||||
wall_upper = None
|
wall_upper = None
|
||||||
|
|
||||||
chmbr.update_mdot(self.mdot(self.t)/self.size)
|
mdot = chamber_mdots[i_chamber]
|
||||||
|
inlet_reg_idx = inlet_reg_indices[i_chamber]
|
||||||
|
T_pre = reg_preheat_temps[inlet_reg_idx]
|
||||||
|
|
||||||
|
# Compute new combustion state (HP equilibrium) based on T_pre
|
||||||
|
if inlet_reg_idx not in cantera_cache:
|
||||||
|
self.gas.TP = T_pre, self.P0
|
||||||
|
self.gas.set_equivalence_ratio(self.phi, self.fuel, self.oxidizer)
|
||||||
|
self.gas.equilibrate('HP')
|
||||||
|
burned_state = self.gas.TPX
|
||||||
|
h0 = self.gas.enthalpy_mass
|
||||||
|
T0 = self.gas.T
|
||||||
|
cantera_cache[inlet_reg_idx] = (T0, burned_state, h0)
|
||||||
|
|
||||||
|
T0, burned_state, h0 = cantera_cache[inlet_reg_idx]
|
||||||
|
|
||||||
|
# Update chamber state variables
|
||||||
|
chmbr.update_mdot(mdot)
|
||||||
|
chmbr.gas.TPX = burned_state
|
||||||
|
chmbr.T0 = T0
|
||||||
|
chmbr.h0 = h0
|
||||||
|
chmbr.T1 = T0 # reset outlet guess to Tad
|
||||||
|
|
||||||
chmbr.update_Twall(
|
chmbr.update_Twall(
|
||||||
Twall0=(
|
Twall0=(
|
||||||
wall_lower.T_chamber if wall_lower else wall_upper.T_chamber),
|
wall_lower.T_chamber if wall_lower else wall_upper.T_chamber),
|
||||||
|
|
@ -735,6 +951,21 @@ class Battery:
|
||||||
if wall_upper:
|
if wall_upper:
|
||||||
wall_upper.update_bc(Q=Q2)
|
wall_upper.update_bc(Q=Q2)
|
||||||
|
|
||||||
|
# Accumulate exhaust properties for charging (outlet) regenerator
|
||||||
|
outlet_reg_idx = outlet_reg_indices[i_chamber]
|
||||||
|
outlet_mdots[outlet_reg_idx] += mdot
|
||||||
|
outlet_T_weighted[outlet_reg_idx] += mdot * chmbr.T1
|
||||||
|
|
||||||
|
# 4. Perform heat exchange for charging regenerators once using mass-weighted exhaust temps
|
||||||
|
active_outlets = range(1, size + 2, 2) if is_a else range(0, size + 1, 2)
|
||||||
|
for j in active_outlets:
|
||||||
|
mdot_tot = outlet_mdots[j]
|
||||||
|
if mdot_tot > 1e-8:
|
||||||
|
T_exh_avg = outlet_T_weighted[j] / mdot_tot
|
||||||
|
self.regenerators[j].heat_exchange(
|
||||||
|
dt_sec, mdot_tot, T_exh_avg, is_discharging=False
|
||||||
|
)
|
||||||
|
|
||||||
# Loop all ovens
|
# Loop all ovens
|
||||||
# update oven wall temperatures using coke charge age
|
# update oven wall temperatures using coke charge age
|
||||||
# solve heat equations of all walls
|
# solve heat equations of all walls
|
||||||
|
|
@ -867,8 +1098,8 @@ class Battery:
|
||||||
|
|
||||||
self.gas_t_history.append(
|
self.gas_t_history.append(
|
||||||
(self.t, [chmbr.T1 for chmbr in self.chambers]))
|
(self.t, [chmbr.T1 for chmbr in self.chambers]))
|
||||||
self.wall_t_history.append((self.t, [(wl.T_chamber, wl.T_internal.data, wl.T_oven, wu.T_oven,
|
self.wall_t_history.append((self.t, [(wl.T_chamber, wl.T_internal.data.copy(), wl.T_oven, wu.T_oven,
|
||||||
wu.T_internal.data, wu.T_chamber) for wl, wu in zip(self.walls_0, self.walls_1)]))
|
wu.T_internal.data.copy(), wu.T_chamber) for wl, wu in zip(self.walls_0, self.walls_1)]))
|
||||||
|
|
||||||
|
|
||||||
def coke_oven_exhaust_stoichiometry(phi=1.0, return_unburned=False):
|
def coke_oven_exhaust_stoichiometry(phi=1.0, return_unburned=False):
|
||||||
|
|
|
||||||
BIN
gas.history2
BIN
gas.history2
Binary file not shown.
BIN
wall.history2
BIN
wall.history2
Binary file not shown.
Loading…
Add table
Reference in a new issue