from .branch import HeatExchanger
from .child import NoVarChildModel, PowerGenerator, PowerLoad, Sink
from .core import (
MultiGridBranchModel,
MultiGridCompoundModel,
MultiGridNodeModel,
Node,
PostProcess,
Var,
model,
)
from .grid import KGPS_KWHPERKG_TO_MW, GasGrid, PowerGrid, WaterGrid
from .network import Network
from .node import Bus, Junction
from .phys.core.hydraulics import junction_mass_flow_balance
from .phys.nonlinear.ac import power_balance_equation
from .phys.nonlinear.hf import SPECIFIC_HEAT_CAP_WATER
# Nominal supply-to-return temperature drop assumed when deriving mass-flow
# initial values from a heat setpoint (matches the convention used by the
# network builders). Only affects solver starting points, never the physics.
_NOMINAL_DT_K = 25.0
def _heat_flow_init_kgs(heat_mw) -> float:
"""Expected heat-side mass flow for a heat setpoint at the nominal ΔT.
APOPT stalls on small-setpoint compounds when every flow Var starts at the
generic 1.0 placeholder, orders of magnitude from the optimum."""
if not isinstance(heat_mw, (int, float)) or isinstance(heat_mw, bool):
return 1.0
return abs(heat_mw) * 1e6 / (SPECIFIC_HEAT_CAP_WATER * _NOMINAL_DT_K)
def _num_or(value, default: float) -> float:
return (
value
if isinstance(value, (int, float)) and not isinstance(value, bool)
else default
)
[docs]
@model
class GenericTransferBranch(MultiGridBranchModel):
def __init__(
self, flow_init_kgs: float = 1.0, p_init_mw: float = 1.0, **kwargs
) -> None:
super().__init__(**kwargs)
# Initial values only - compounds pass setpoint-derived magnitudes so
# solvers don't start orders of magnitude from the optimum.
self._mass_flow_pos = Var(flow_init_kgs, min=0, name="mass_flow_pos_kgs")
self._mass_flow_neg = Var(flow_init_kgs, min=0, name="mass_flow_neg_kgs")
self.on_off = 1
self._p_mw = Var(p_init_mw, name="transfer_p_mw")
self._q_mvar = Var(1, name="transfer_q_mvar")
self._t_from_pu = Var(1, min=0, max=2, name="t_from_pu")
self._t_to_pu = Var(1, min=0, max=2, name="t_to_pu")
[docs]
def is_cp(self):
return False
[docs]
def init(self, grids):
if type(grids) is WaterGrid or (type(grids) is dict and WaterGrid in grids):
self.mass_flow_pos_kgs = self._mass_flow_pos
self.mass_flow_neg_kgs = self._mass_flow_neg
self.t_from_pu = self._t_from_pu
self.t_to_pu = self._t_to_pu
if type(grids) is GasGrid or (type(grids) is dict and GasGrid in grids):
self.mass_flow_pos_kgs = self._mass_flow_pos
self.mass_flow_neg_kgs = self._mass_flow_neg
self.gas_mass_flow = self.mass_flow_pos_kgs
if type(grids) is PowerGrid or (type(grids) is dict and PowerGrid in grids):
self.p_to_mw = self._p_mw
self.p_from_mw = self._p_mw
self.q_to_mvar = self._q_mvar
self.q_from_mvar = self._q_mvar
[docs]
def equations(self, grids, from_node_model, to_node_model, **kwargs):
eqs = []
if type(grids) is WaterGrid or (type(grids) is dict and WaterGrid in grids):
eqs += [self.t_from_pu == self.t_to_pu]
eqs += [self.t_from_pu == from_node_model.t_pu]
eqs += [to_node_model.t_pu == self.t_to_pu]
eqs += [to_node_model.t_pu == from_node_model.t_pu]
eqs += [from_node_model.pressure_pu == to_node_model.pressure_pu]
if type(grids) is GasGrid or (type(grids) is dict and GasGrid in grids):
pass
return eqs
def _is_zeroed(regulation):
"""True when ``ignore_compound``'s ``set_active(False)`` pinned the
regulation to the plain number 0; a controllable-CP Var never counts."""
return (
isinstance(regulation, (int, float))
and not isinstance(regulation, bool)
and regulation == 0
)
[docs]
@model
class GasToHeatControlNode(MultiGridNodeModel, Junction):
def __init__(
self, gas_mass_flow_kgs, efficiency_heat, hhv, regulation=1, **kwargs
) -> None:
super().__init__(**kwargs)
self.efficiency_heat = efficiency_heat
self._hhv = hhv
self.regulation = regulation
self.gas_mass_flow_kgs = gas_mass_flow_kgs
# Initialize at the setpoint solution: APOPT stalls on tiny-setpoint
# instances when started from a generic placeholder far from optimum.
heat_mw_init = (
-efficiency_heat * gas_mass_flow_kgs * KGPS_KWHPERKG_TO_MW * hhv
if isinstance(gas_mass_flow_kgs, (int, float))
else -1e-3
)
self.heat_mw = Var(min(heat_mw_init, -1e-6), max=0, name="g2h_heat_mw")
self.t_k = Var(350, min=200, max=800, name="t_k")
self.t_pu = Var(1, min=0, max=2, name="t_pu")
# pressure_pa is post-solve only (= pressure_pu \cdot pressure_ref_pa); compute it
# outside the solver. Real closure attached in equations() (needs grid).
self.pressure_pa = PostProcess(lambda v: float("nan"))
self.pressure_pu = Var(1, min=0, max=2, name="pressure_pu")
[docs]
def equations(self, grid, from_branch_models, to_branch_models, childs, **kwargs):
heat_to_branches = [
branch
for branch in to_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
heat_from_branches = [
branch
for branch in from_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
gas_to_branches = [
branch for branch in to_branch_models if "gas_mass_flow" in branch.vars
]
sub_he = next((b for b in heat_from_branches if isinstance(b, SubHE)), None)
if sub_he is None:
if not _is_zeroed(self.regulation):
raise ValueError(
"GasToHeatControlNode requires a SubHE heat-exchanger branch on the "
"heat-return side. Build via GasToHeat.create(...) so the SubHE is wired up."
)
# Heat side ignored: ignore_compound zeroed the regulation and the
# solver filtered the SubHE with the ignored heat junctions.
# Degrade to a gas-only node with zero heat output.
gas_eqs = self.calc_signed_mass_flow(
[], gas_to_branches, [Sink(self.gas_mass_flow_kgs * self.regulation)]
)
self.pressure_pa = PostProcess(
lambda v, ref=grid[0].pressure_ref_pa: v.pressure_pu * ref
)
return [
junction_mass_flow_balance(gas_eqs),
self.heat_mw == 0,
self.t_pu == 1,
self.t_pu == self.t_k / grid[0].t_ref_k,
]
gas_eqs = self.calc_signed_mass_flow(
[], gas_to_branches, [Sink(self.gas_mass_flow_kgs * self.regulation)]
)
heat_eqs = self.calc_signed_mass_flow(heat_from_branches, heat_to_branches, [])
heat_energy_eqs = self.calc_signed_heat_flow(
heat_from_branches, heat_to_branches, [], None
)
self.pressure_pa = PostProcess(
lambda v, ref=grid[0].pressure_ref_pa: v.pressure_pu * ref
)
return [
junction_mass_flow_balance(heat_eqs),
junction_mass_flow_balance(heat_energy_eqs),
junction_mass_flow_balance(gas_eqs),
sub_he.q_mw
== -self.efficiency_heat
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * self._hhv),
self.heat_mw == sub_he.q_mw,
self.t_pu == self.t_k / grid[0].t_ref_k,
]
[docs]
@model
class PowerToHeatControlNode(MultiGridNodeModel, Junction, Bus):
def __init__(
self, load_p_mw, load_q_mvar, efficiency, regulation=1, **kwargs
) -> None:
super().__init__(**kwargs)
self.load_q_mvar = load_q_mvar
self.efficiency = efficiency
self.regulation = regulation
self.el_mw = load_p_mw
# Initialize at the setpoint solution (see GasToHeatControlNode).
heat_mw_init = (
-efficiency * load_p_mw if isinstance(load_p_mw, (int, float)) else -1e-3
)
self.heat_mw = Var(min(heat_mw_init, -1e-6), max=0, name="p2h_heat_mw")
self.t_k = Var(350, min=200, max=800, name="t_k")
self.t_pu = Var(1, min=0, max=2, name="t_pu")
self.pressure_squared_pu = Var(1, min=0.5, max=3, name="pressure_squared_pu")
self.pressure_pu = Var(1, min=0.5, max=3, name="pressure_pu")
[docs]
def equations(self, grid, from_branch_models, to_branch_models, childs, **kwargs):
heat_to_branches = [
branch
for branch in to_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
heat_from_branches = [
branch
for branch in from_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
power_to_branches = [
branch for branch in to_branch_models if "p_from_mw" in branch.vars
]
sub_he = next((b for b in heat_to_branches if isinstance(b, SubHE)), None)
if sub_he is None:
if not _is_zeroed(self.regulation):
raise ValueError(
"PowerToHeatControlNode requires a SubHE heat-exchanger branch on the "
"heat-supply side. Build via PowerToHeat.create(...) so the SubHE is wired up."
)
# Heat side ignored (see GasToHeatControlNode): degrade to a
# power-only node with zero load and zero heat output.
power_eqs = self.calc_signed_power_values(
[],
power_to_branches,
[PowerLoad(self.el_mw, self.load_q_mvar, regulation=self.regulation)],
)
return [
self.heat_mw == 0,
sum(power_eqs[0]) == 0,
sum(power_eqs[1]) == 0,
self.t_pu == 1,
self.t_k == self.t_pu * grid[1].t_ref_k,
]
power_eqs = self.calc_signed_power_values(
[],
power_to_branches,
[PowerLoad(self.el_mw, self.load_q_mvar, regulation=self.regulation)],
)
heat_eqs = self.calc_signed_mass_flow(heat_from_branches, heat_to_branches, [])
heat_energy_eqs = self.calc_signed_heat_flow(
heat_from_branches, heat_to_branches, [], None
)
return [
junction_mass_flow_balance(heat_eqs),
junction_mass_flow_balance(heat_energy_eqs),
sub_he.q_mw == -self.efficiency * self.el_mw * self.regulation,
self.heat_mw == sub_he.q_mw,
sum(power_eqs[0]) == 0,
sum(power_eqs[1]) == 0,
self.t_k == self.t_pu * grid[1].t_ref_k,
]
[docs]
class SubHE(HeatExchanger):
"""Subordinate heat exchanger used inside compound models (CHP, G2H, P2H)."""
[docs]
@model
class CHPControlNode(MultiGridNodeModel, Junction, Bus):
"""Control node for a CHP unit; couples power, heat, and gas domains."""
def __init__(
self,
mass_flow_capacity_kgs,
efficiency_power,
efficiency_heat,
hhv,
q_mvar=0,
regulation=1,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.efficiency_heat = efficiency_heat
self.efficiency_power = efficiency_power
self.gen_q_mvar = q_mvar
self._hhv = hhv
self.regulation = regulation
# Initialize at the setpoint solution (see GasToHeatControlNode).
if isinstance(mass_flow_capacity_kgs, (int, float)):
el_mw_init = (
-efficiency_power * mass_flow_capacity_kgs * KGPS_KWHPERKG_TO_MW * hhv
)
heat_mw_init = (
-efficiency_heat * mass_flow_capacity_kgs * KGPS_KWHPERKG_TO_MW * hhv
)
else:
el_mw_init, heat_mw_init = -1, -1e-3
self.el_mw = Var(min(el_mw_init, -1e-6), max=0, name="chp_el_mw")
self.gas_mass_flow_kgs = mass_flow_capacity_kgs
self.heat_mw = Var(min(heat_mw_init, -1e-6), max=0, name="chp_heat_mw")
self.t_k = Var(350, min=200, max=800, name="t_k")
self.t_pu = Var(1, min=0, max=2, name="t_pu")
# pressure_pa is post-solve only (see GasToHeatControlNode).
self.pressure_pa = PostProcess(lambda v: float("nan"))
self.pressure_pu = Var(1, min=0, max=2, name="pressure_pu")
[docs]
def equations(self, grid, from_branch_models, to_branch_models, childs, **kwargs):
heat_to_branches = [
branch
for branch in to_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
heat_from_branches = [
branch
for branch in from_branch_models
if "t_from_pu" in branch.vars or isinstance(branch, SubHE)
]
gas_to_branches = [
branch for branch in to_branch_models if "gas_mass_flow" in branch.vars
]
power_from_branches = [
branch for branch in from_branch_models if "p_to_mw" in branch.vars
]
sub_he = next((b for b in heat_from_branches if isinstance(b, SubHE)), None)
if sub_he is None:
if not _is_zeroed(self.regulation):
raise ValueError(
"CHPControlNode requires a SubHE heat-exchanger branch on the "
"heat-return side. Build via CHP.create(...) so the SubHE is wired up."
)
# Heat side ignored (see GasToHeatControlNode): degrade to a
# gas+power node with zero generation and zero heat output.
power_eqs = self.calc_signed_power_values(
power_from_branches, [], [PowerGenerator(self.el_mw, self.gen_q_mvar)]
)
gas_eqs = self.calc_signed_mass_flow(
[], gas_to_branches, [Sink(self.gas_mass_flow_kgs * self.regulation)]
)
self.pressure_pa = PostProcess(
lambda v, ref=grid[1].pressure_ref_pa: v.pressure_pu * ref
)
return [
junction_mass_flow_balance(gas_eqs),
power_balance_equation(power_eqs[0]),
power_balance_equation(power_eqs[1]),
self.el_mw == 0,
self.heat_mw == 0,
self.t_pu == 1,
self.t_k == self.t_pu * grid[1].t_ref_k,
]
power_eqs = self.calc_signed_power_values(
power_from_branches, [], [PowerGenerator(self.el_mw, self.gen_q_mvar)]
)
gas_eqs = self.calc_signed_mass_flow(
[], gas_to_branches, [Sink(self.gas_mass_flow_kgs * self.regulation)]
)
heat_eqs = self.calc_signed_mass_flow(heat_from_branches, heat_to_branches, [])
heat_energy_eqs = self.calc_signed_heat_flow(
heat_from_branches, heat_to_branches, [], None
)
self.pressure_pa = PostProcess(
lambda v, ref=grid[1].pressure_ref_pa: v.pressure_pu * ref
)
return [
junction_mass_flow_balance(heat_eqs),
junction_mass_flow_balance(heat_energy_eqs),
junction_mass_flow_balance(gas_eqs),
power_balance_equation(power_eqs[0]),
power_balance_equation(power_eqs[1]),
sub_he.q_mw
== -self.efficiency_heat
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * self._hhv),
self.el_mw
== -self.efficiency_power
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * self._hhv),
self.heat_mw == sub_he.q_mw,
self.t_k == self.t_pu * grid[1].t_ref_k,
]
[docs]
@model
class CHP(MultiGridCompoundModel):
def __init__(
self,
diameter_m: float,
efficiency_power: float,
efficiency_heat: float,
mass_flow_setpoint_kgs: float,
q_mvar_setpoint: float = 0,
temperature_ext_k: float = 293,
regulation=1,
) -> None:
self.diameter_m = diameter_m
self.temperature_ext_k = temperature_ext_k
self.regulation = regulation
self.efficiency_power = efficiency_power
self.efficiency_heat = efficiency_heat
self.mass_flow_setpoint_kgs = mass_flow_setpoint_kgs
self.mass_flow_kgs = mass_flow_setpoint_kgs
self.q_mvar = q_mvar_setpoint
self._old_regulation = self.regulation
[docs]
def set_active(self, activation_flag):
if activation_flag:
# Don't overwrite when controllable_cps has promoted the attr to a
# Var - restore only the case where set_active(False) zeroed it.
if isinstance(
self._control_node.gas_mass_flow_kgs, (int, float)
) and not isinstance(self._control_node.gas_mass_flow_kgs, bool):
self._control_node.gas_mass_flow_kgs = self.mass_flow_setpoint_kgs
if isinstance(
self._control_node.regulation, (int, float)
) and not isinstance(self._control_node.regulation, bool):
self._control_node.regulation = self._old_regulation
else:
self._old_regulation = self._control_node.regulation
self._control_node.gas_mass_flow_kgs = 0
self._control_node.regulation = 0
[docs]
def create(
self,
network: Network,
gas_node: Node,
heat_node: Node,
heat_return_node: Node,
power_node: Node,
):
self._gas_grid = gas_node.grid
hhv = gas_node.grid.higher_heating_value_kwh_per_kg
self._control_node = CHPControlNode(
self.mass_flow_kgs,
self.efficiency_power,
self.efficiency_heat,
hhv,
regulation=self.regulation,
)
node_id_control = network.node(
self._control_node,
grid=[power_node.grid, heat_node.grid, gas_node.grid],
position=power_node.position,
)
heat_mw = (
self.efficiency_heat
* _num_or(self.mass_flow_kgs, 1.0)
* KGPS_KWHPERKG_TO_MW
* hhv
)
el_mw = (
self.efficiency_power
* _num_or(self.mass_flow_kgs, 1.0)
* KGPS_KWHPERKG_TO_MW
* hhv
)
network.branch(
GenericTransferBranch(flow_init_kgs=_num_or(self.mass_flow_kgs, 1.0)),
gas_node.id,
node_id_control,
)
network.branch(
GenericTransferBranch(flow_init_kgs=_heat_flow_init_kgs(heat_mw)),
heat_node.id,
node_id_control,
)
network.branch(
SubHE(Var(-heat_mw)),
node_id_control,
heat_return_node.id,
grid=heat_return_node.grid,
)
network.branch(
GenericTransferBranch(p_init_mw=el_mw), node_id_control, power_node.id
)
[docs]
@model
class GasToHeat(MultiGridCompoundModel):
def __init__(
self, heat_energy_mw, diameter_m, temperature_ext_k, efficiency, regulation=1
) -> None:
self.diameter_m = diameter_m
self.temperature_ext_k = temperature_ext_k
self.efficiency = efficiency
self.heat_energy_mw = -heat_energy_mw
self.regulation = regulation
self._old_regulation = self.regulation
[docs]
def set_active(self, activation_flag):
if activation_flag:
# See CHP.set_active - skip restore when attr is already an LP Var.
if isinstance(
self._control_node.regulation, (int, float)
) and not isinstance(self._control_node.regulation, bool):
self._control_node.regulation = self._old_regulation
else:
self._old_regulation = self._control_node.regulation
self._control_node.regulation = 0
[docs]
def create(
self, network: Network, gas_node: Node, heat_node: Node, heat_return_node: Node
):
self._gas_grid = gas_node.grid
hhv = gas_node.grid.higher_heating_value_kwh_per_kg
# m = |q| / (eff \cdot KGPS_KWHPERKG_TO_MW \cdot hhv)
self.gas_mass_flow_kgs = abs(self.heat_energy_mw) / (
self.efficiency * KGPS_KWHPERKG_TO_MW * hhv
)
self._control_node = GasToHeatControlNode(
self.gas_mass_flow_kgs,
self.efficiency,
hhv,
regulation=self.regulation,
)
node_id_control = network.node(
self._control_node,
grid=[heat_node.grid, gas_node.grid],
position=gas_node.position,
)
network.branch(
GenericTransferBranch(flow_init_kgs=_num_or(self.gas_mass_flow_kgs, 1.0)),
gas_node.id,
node_id_control,
)
network.branch(
GenericTransferBranch(
flow_init_kgs=_heat_flow_init_kgs(self.heat_energy_mw)
),
heat_node.id,
node_id_control,
)
network.branch(
SubHE(Var(self.heat_energy_mw)),
node_id_control,
heat_return_node.id,
grid=heat_return_node.grid,
)
[docs]
@model
class PowerToHeat(MultiGridCompoundModel):
def __init__(
self,
heat_energy_mw,
diameter_m,
temperature_ext_k,
efficiency,
q_mvar_setpoint=0,
regulation=1,
) -> None:
self.diameter_m = diameter_m
self.temperature_ext_k = temperature_ext_k
self.efficiency = efficiency
self.heat_energy_mw = heat_energy_mw
self.load_p_mw = heat_energy_mw / efficiency
self.load_q_mvar = q_mvar_setpoint
self.regulation = regulation
self._old_regulation = self.regulation
[docs]
def set_active(self, activation_flag):
if activation_flag:
# See ``CHP.set_active`` - skip restore when the attribute is
# already an LP variable.
if isinstance(
self._control_node.regulation, (int, float)
) and not isinstance(self._control_node.regulation, bool):
self._control_node.regulation = self._old_regulation
else:
self._old_regulation = self._control_node.regulation
self._control_node.regulation = 0
[docs]
def create(
self,
network: Network,
power_node: Node,
heat_node: Node,
heat_return_node: Node,
):
self._control_node = PowerToHeatControlNode(
self.load_p_mw,
self.load_q_mvar,
self.efficiency,
regulation=self.regulation,
)
node_id_control = network.node(
self._control_node,
grid=[power_node.grid, heat_node.grid],
position=power_node.position,
)
network.branch(
GenericTransferBranch(p_init_mw=_num_or(self.load_p_mw, 1.0)),
power_node.id,
node_id_control,
)
network.branch(
GenericTransferBranch(
flow_init_kgs=_heat_flow_init_kgs(self.heat_energy_mw)
),
node_id_control,
heat_return_node.id,
)
network.branch(
SubHE(Var(-self.heat_energy_mw)),
heat_node.id,
node_id_control,
grid=heat_node.grid,
)
[docs]
@model
class GasToPower(MultiGridBranchModel):
def __init__(
self, efficiency, p_mw_setpoint, q_mvar_setpoint=0, regulation=1
) -> None:
super().__init__()
self.efficiency = efficiency
self.el_mw = -p_mw_setpoint
self.gas_mass_flow_kgs = Var(1, min=0, name="g2p_gas_kgps")
self.on_off = 1
self.p_to_mw = Var(-p_mw_setpoint, max=0, name="g2p_p_to_mw")
self.q_to_mvar = -q_mvar_setpoint
self.from_mass_flow_kgs = Var(1, min=0, name="g2p_from_mass_flow")
self.regulation = regulation
[docs]
def loss_percent(self):
return 1 - self.efficiency
[docs]
def equations(self, grids, from_node_model, to_node_model, **kwargs):
return [
self.p_to_mw == self.regulation * self.el_mw,
-self.p_to_mw
== self.efficiency
* self.from_mass_flow_kgs
* (KGPS_KWHPERKG_TO_MW * grids[GasGrid].higher_heating_value_kwh_per_kg),
self.gas_mass_flow_kgs == self.from_mass_flow_kgs,
]
[docs]
@model
class PowerToGas(MultiGridBranchModel):
def __init__(
self,
efficiency,
mass_flow_setpoint_kgs,
consume_q_mvar_setpoint=0,
regulation=1,
) -> None:
super().__init__()
self.efficiency = efficiency
self.gas_mass_flow_kgs = -mass_flow_setpoint_kgs
self.el_mw = Var(1.1, min=0, name="p2g_el_mw")
self.on_off = 1
self.p_from_mw = Var(1, min=0, name="p2g_p_from_mw")
self.q_from_mvar = consume_q_mvar_setpoint
self.to_mass_flow_kgs = Var(
self.gas_mass_flow_kgs, max=0, name="p2g_to_mass_flow"
)
self.regulation = regulation
[docs]
def loss_percent(self):
return 1 - self.efficiency
[docs]
def equations(self, grids, from_node_model, to_node_model, **kwargs):
return [
self.to_mass_flow_kgs
== -self.efficiency
* self.p_from_mw
* (
1
/ (grids[GasGrid].higher_heating_value_kwh_per_kg * KGPS_KWHPERKG_TO_MW)
),
self.p_from_mw >= 0,
self.p_from_mw == self.el_mw,
self.gas_mass_flow_kgs * self.regulation
== -self.efficiency
* self.el_mw
* (
1
/ (grids[GasGrid].higher_heating_value_kwh_per_kg * KGPS_KWHPERKG_TO_MW)
),
]
[docs]
@model
class SubHG(NoVarChildModel):
"""Subordinate node-based heat generator used inside :class:`CHPHG`.
Like HeatGenerator but with q_mw_heat as a Var constrained by the parent
compound's control-node equations. Two-endpoint HG variants
(GasToHeatHG / PowerToHeatHG) don't use this - they carry q_mw_heat
directly on the branch.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.q_mw_heat = Var(-1e-3, name="sub_hg_q_mw_heat")
def _gas_grid_of(grid):
if isinstance(grid, list):
return next(g for g in grid if isinstance(g, GasGrid))
return grid
[docs]
@model
class CHPHGControlNode(MultiGridNodeModel, Junction, Bus):
"""CHP control node using a node-based HeatGenerator (no HX branch).
Like :class:`CHPControlNode` but only on power+gas; heat goes through a
:class:`SubHG` child attached at the heat node by :class:`CHPHG`.
"""
def __init__(
self,
mass_flow_capacity_kgs,
efficiency_power,
efficiency_heat,
hhv,
sub_hg,
q_mvar=0,
regulation=1,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.efficiency_heat = efficiency_heat
self.efficiency_power = efficiency_power
self.gen_q_mvar = q_mvar
self._hhv = hhv
self._sub_hg = sub_hg
self.regulation = regulation
self.el_mw = Var(-1, max=0, name="chp_hg_el_mw")
self.gas_mass_flow_kgs = mass_flow_capacity_kgs
self.heat_mw = Var(-1e-3, max=0, name="chp_hg_heat_mw")
self.t_k = Var(350, min=200, max=800, name="t_k")
self.t_pu = Var(1, min=0, max=2, name="t_pu")
# pressure_pa is post-solve only (see GasToHeatControlNode).
self.pressure_pa = PostProcess(lambda v: float("nan"))
self.pressure_pu = Var(1, min=0, max=2, name="pressure_pu")
[docs]
def equations(self, grid, from_branch_models, to_branch_models, childs, **kwargs):
gas_to_branches = [
branch for branch in to_branch_models if "gas_mass_flow" in branch.vars
]
power_from_branches = [
branch for branch in from_branch_models if "p_to_mw" in branch.vars
]
power_eqs = self.calc_signed_power_values(
power_from_branches, [], [PowerGenerator(self.el_mw, self.gen_q_mvar)]
)
gas_eqs = self.calc_signed_mass_flow(
[], gas_to_branches, [Sink(self.gas_mass_flow_kgs * self.regulation)]
)
gas_grid = _gas_grid_of(grid)
self.pressure_pa = PostProcess(
lambda v, ref=gas_grid.pressure_ref_pa: v.pressure_pu * ref
)
return [
junction_mass_flow_balance(gas_eqs),
power_balance_equation(power_eqs[0]),
power_balance_equation(power_eqs[1]),
self._sub_hg.q_mw_heat
== -self.efficiency_heat
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * self._hhv),
self.el_mw
== -self.efficiency_power
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * self._hhv),
self.heat_mw == self._sub_hg.q_mw_heat,
]
[docs]
@model
class CHPHG(MultiGridCompoundModel):
"""HeatGenerator-based :class:`CHP` variant: heat is injected via a
:class:`SubHG` child at ``heat_node`` instead of via an HX branch. No
``heat_return_node`` / ``diameter_m`` required."""
def __init__(
self,
efficiency_power: float,
efficiency_heat: float,
mass_flow_setpoint_kgs: float,
q_mvar_setpoint: float = 0,
regulation=1,
) -> None:
self.regulation = regulation
self.efficiency_power = efficiency_power
self.efficiency_heat = efficiency_heat
self.mass_flow_setpoint_kgs = mass_flow_setpoint_kgs
self.mass_flow_kgs = mass_flow_setpoint_kgs
self.q_mvar = q_mvar_setpoint
self._old_regulation = self.regulation
[docs]
def create(
self,
network: Network,
gas_node: Node,
heat_node: Node,
power_node: Node,
):
self._gas_grid = gas_node.grid
hhv = gas_node.grid.higher_heating_value_kwh_per_kg
self._sub_hg = SubHG()
self._control_node = CHPHGControlNode(
self.mass_flow_kgs,
self.efficiency_power,
self.efficiency_heat,
hhv,
self._sub_hg,
q_mvar=self.q_mvar,
regulation=self.regulation,
)
node_id_control = network.node(
self._control_node,
grid=[power_node.grid, gas_node.grid],
position=power_node.position,
)
network.branch(GenericTransferBranch(), gas_node.id, node_id_control)
network.branch(GenericTransferBranch(), node_id_control, power_node.id)
network.child(self._sub_hg, attach_to_node_id=heat_node.id)
[docs]
@model
class GasToHeatHG(MultiGridBranchModel):
"""Two-endpoint Gas→Heat coupling (gas withdrawal at from-end, q_mw_heat
injection at to-end). Junction heat balance picks up ``q_mw_heat`` directly."""
def __init__(self, heat_energy_mw, efficiency, regulation=1) -> None:
super().__init__()
self.efficiency = efficiency
self.heat_energy_mw = -heat_energy_mw
self.on_off = 1
self.regulation = regulation
self.q_mw_heat = Var(self.heat_energy_mw, max=0, name="g2h_hg_q_mw_heat")
self.from_mass_flow_kgs = Var(1, min=0, name="g2h_hg_from_mass_flow")
self.gas_mass_flow_kgs = 0 # set in init() once hhv is known
[docs]
def init(self, grids):
hhv = grids[GasGrid].higher_heating_value_kwh_per_kg
self.gas_mass_flow_kgs = abs(self.heat_energy_mw) / (
self.efficiency * KGPS_KWHPERKG_TO_MW * hhv
)
[docs]
def loss_percent(self):
return 1 - self.efficiency
[docs]
def equations(self, grids, from_node_model, to_node_model, **kwargs):
hhv = grids[GasGrid].higher_heating_value_kwh_per_kg
return [
self.q_mw_heat
== -self.efficiency
* self.gas_mass_flow_kgs
* self.regulation
* (KGPS_KWHPERKG_TO_MW * hhv),
self.from_mass_flow_kgs == self.gas_mass_flow_kgs * self.regulation,
]
[docs]
@model
class PowerToHeatHG(MultiGridBranchModel):
"""Two-endpoint Power→Heat coupling (p_from_mw at from-end, q_mw_heat at
to-end). Junction heat balance picks up ``q_mw_heat`` directly."""
def __init__(
self, heat_energy_mw, efficiency, q_mvar_setpoint=0, regulation=1
) -> None:
super().__init__()
self.efficiency = efficiency
self.heat_energy_mw = heat_energy_mw
self.load_p_mw = heat_energy_mw / efficiency
self.load_q_mvar = q_mvar_setpoint
self.on_off = 1
self.regulation = regulation
self.q_mw_heat = Var(-heat_energy_mw, max=0, name="p2h_hg_q_mw_heat")
self.p_from_mw = Var(self.load_p_mw, min=0, name="p2h_hg_p_from_mw")
self.q_from_mvar = q_mvar_setpoint
[docs]
def loss_percent(self):
return 1 - self.efficiency
[docs]
def equations(self, grids, from_node_model, to_node_model, **kwargs):
return [
self.q_mw_heat == -self.efficiency * self.load_p_mw * self.regulation,
self.p_from_mw == self.load_p_mw * self.regulation,
]