diff --git a/src/libecalc/domain/energy/infrastructure_contracts.py b/src/libecalc/domain/energy/infrastructure_contracts.py new file mode 100644 index 0000000000..0c819c9b1f --- /dev/null +++ b/src/libecalc/domain/energy/infrastructure_contracts.py @@ -0,0 +1,34 @@ +"""Thin protocols that infrastructure models satisfy.""" + +from typing import Protocol + + +class TurbineEnergyUsage(Protocol): + values: list[float] + + +class TurbineEnergyResult(Protocol): + energy_usage: TurbineEnergyUsage + + +class TurbineResult(Protocol): + exceeds_maximum_load: list[bool] + + def get_energy_result(self) -> TurbineEnergyResult: ... + + +class TurbineDriver(Protocol): + """Anything that converts mechanical load [MW] to fuel, with load limits.""" + + def evaluate(self, load) -> TurbineResult: ... + + @property + def max_power(self) -> float: ... + + +class FuelConverter(Protocol): + """Anything that converts electrical power [MW] to fuel [Sm³/day].""" + + def evaluate_fuel_usage(self, power_mw: float) -> float: ... + + def evaluate_power_capacity_margin(self, power_mw: float) -> float: ... diff --git a/src/libecalc/domain/energy/network/__init__.py b/src/libecalc/domain/energy/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/libecalc/domain/energy/network/energy_node.py b/src/libecalc/domain/energy/network/energy_node.py new file mode 100644 index 0000000000..c602094f24 --- /dev/null +++ b/src/libecalc/domain/energy/network/energy_node.py @@ -0,0 +1,35 @@ +import uuid +from abc import ABC, abstractmethod +from typing import NewType + +from libecalc.domain.energy.network.energy_stream import EnergyStream + +EnergyNodeId = NewType("EnergyNodeId", uuid.UUID) + + +def create_energy_node_id() -> EnergyNodeId: + return EnergyNodeId(uuid.uuid4()) + + +class EnergyNode(ABC): + """Base for anything in the energy network.""" + + @abstractmethod + def get_id(self) -> EnergyNodeId: ... + + +class Consumer(EnergyNode, ABC): + """Knows its own demand — leaf node, entry point of the network.""" + + @abstractmethod + def get_demand(self) -> EnergyStream: ... + + +class Provider(EnergyNode, ABC): + """Receives demand, transforms or fulfills it.""" + + @abstractmethod + def supply(self, demand: EnergyStream) -> EnergyStream: ... + + @abstractmethod + def get_capacity_margin(self, demand: EnergyStream) -> float: ... diff --git a/src/libecalc/domain/energy/network/energy_stream.py b/src/libecalc/domain/energy/network/energy_stream.py new file mode 100644 index 0000000000..56a84d2b8d --- /dev/null +++ b/src/libecalc/domain/energy/network/energy_stream.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from libecalc.common.units import Unit + + +class EnergyType(Enum): + MECHANICAL = "MECHANICAL" + ELECTRICAL = "ELECTRICAL" + FUEL = "FUEL" + + +@dataclass(frozen=True) +class EnergyStream: + """What flows between energy nodes.""" + + energy_type: EnergyType + value: float + unit: Unit + + @staticmethod + def mechanical(power_mw: float) -> EnergyStream: + return EnergyStream(energy_type=EnergyType.MECHANICAL, value=power_mw, unit=Unit.MEGA_WATT) + + @staticmethod + def electrical(power_mw: float) -> EnergyStream: + return EnergyStream(energy_type=EnergyType.ELECTRICAL, value=power_mw, unit=Unit.MEGA_WATT) + + @staticmethod + def fuel(rate_sm3_per_day: float) -> EnergyStream: + return EnergyStream(energy_type=EnergyType.FUEL, value=rate_sm3_per_day, unit=Unit.STANDARD_CUBIC_METER_PER_DAY) diff --git a/src/libecalc/domain/energy/network/energy_system.py b/src/libecalc/domain/energy/network/energy_system.py new file mode 100644 index 0000000000..4374f20384 --- /dev/null +++ b/src/libecalc/domain/energy/network/energy_system.py @@ -0,0 +1,144 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from libecalc.domain.energy.network.energy_node import ( + Consumer, + EnergyNode, + EnergyNodeId, + Provider, + create_energy_node_id, +) +from libecalc.domain.energy.network.energy_stream import EnergyStream, EnergyType + + +@dataclass(frozen=True) +class EnergyChainResult: + """Complete result from running an energy chain.""" + + demand: EnergyStream # mechanical demand from consumer + fuel: EnergyStream # fuel consumption from supply chain + capacity_margin_mw: float # tightest margin across all providers + + +class EnergySystem(Provider, ABC): + """A composite provider that contains other energy nodes.""" + + @abstractmethod + def get_children(self) -> list[EnergyNode]: ... + + +@dataclass +class EnergyChain: + """A consumer connected to its supply chain.""" + + consumer: Consumer + supply_chain: EnergySystem + + def run(self) -> EnergyChainResult: + """Run the full energy chain: demand → supply → fuel + margin.""" + demand = self.consumer.get_demand() + fuel = self.supply_chain.supply(demand) + capacity_margin = self.supply_chain.get_capacity_margin(demand) + return EnergyChainResult( + demand=demand, + fuel=fuel, + capacity_margin_mw=capacity_margin, + ) + + def get_all_nodes(self) -> list[EnergyNode]: + """Full graph including demand side.""" + return [self.consumer, *self.supply_chain.get_children()] + + +class PowerBus(EnergySystem): + """ + Distributes electrical demand across multiple sources by priority. + + Sources are tried in order. If a source cannot fully meet the remaining + demand (indicated by get_capacity_margin), it delivers what it can and the + remainder spills to the next source. + """ + + def __init__(self, sources: list[Provider]): + self._id = create_energy_node_id() + self._sources = sources + + def get_id(self) -> EnergyNodeId: + return self._id + + def get_children(self) -> list[EnergyNode]: + return list(self._sources) + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.ELECTRICAL: + raise ValueError(f"Power bus expects electrical demand, got {demand.energy_type}") + + remaining = demand.value + total_fuel = 0.0 + + for source in self._sources: + if remaining <= 0: + break + + margin = source.get_capacity_margin(EnergyStream.electrical(remaining)) + if margin < 0: + # Source can only partially cover remaining demand. + deliverable = max(0.0, remaining + margin) + result = source.supply(EnergyStream.electrical(deliverable)) + total_fuel += result.value + remaining -= deliverable + continue + + # Source can cover all remaining demand. + result = source.supply(EnergyStream.electrical(remaining)) + total_fuel += result.value + remaining = 0.0 + + return EnergyStream.fuel(total_fuel) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Total spare capacity across all sources.""" + total_margin = 0.0 + for source in self._sources: + total_margin += max(0.0, source.get_capacity_margin(demand)) + return total_margin - demand.value + + +class SerialEnergySystem(EnergySystem): + """ + Chain of providers where each node transforms demand and passes it to the next. + + Demand enters the first node. If the node outputs a non-fuel stream (e.g. electrical), + that becomes the demand for the next node. When a node outputs fuel, the chain terminates. + """ + + def __init__(self, nodes: list[Provider]): + self._id = create_energy_node_id() + self._nodes = nodes + + def get_id(self) -> EnergyNodeId: + return self._id + + def get_children(self) -> list[EnergyNode]: + return list(self._nodes) + + def supply(self, demand: EnergyStream) -> EnergyStream: + """Propagate demand through each node until fuel is reached.""" + current = demand + for node in self._nodes: + current = node.supply(current) + if current.energy_type == EnergyType.FUEL: + return current + return current + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Tightest margin across all nodes in the chain.""" + min_margin = float("inf") + current = demand + for node in self._nodes: + margin = node.get_capacity_margin(current) + min_margin = min(min_margin, margin) + current = node.supply(current) + if current.energy_type == EnergyType.FUEL: + break + return min_margin diff --git a/src/libecalc/domain/energy/network/nodes/__init__.py b/src/libecalc/domain/energy/network/nodes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/libecalc/domain/energy/network/nodes/consumers.py b/src/libecalc/domain/energy/network/nodes/consumers.py new file mode 100644 index 0000000000..cfcfc83cc4 --- /dev/null +++ b/src/libecalc/domain/energy/network/nodes/consumers.py @@ -0,0 +1,47 @@ +from libecalc.domain.energy.network.energy_node import Consumer, EnergyNodeId, create_energy_node_id +from libecalc.domain.energy.network.energy_stream import EnergyStream + + +class RotatingEquipment(Consumer): + """A single piece of rotating equipment as an energy node.""" + + def __init__(self, demand: float): + self._id = create_energy_node_id() + self._demand = demand + + def get_id(self) -> EnergyNodeId: + return self._id + + def get_demand(self) -> EnergyStream: + return EnergyStream.mechanical(power_mw=self._demand) + + +class Shaft(Consumer): + """ + Aggregates mechanical demand from rotating equipment on a common shaft, + adjusted for mechanical losses (bearings, gearbox, friction). + """ + + def __init__(self, rotating_equipment: list[RotatingEquipment], mechanical_efficiency: float = 1.0): + self._id = create_energy_node_id() + self._rotating_equipment = rotating_equipment + self._mechanical_efficiency = mechanical_efficiency + + def get_id(self) -> EnergyNodeId: + return self._id + + def get_children(self) -> list[RotatingEquipment]: + return self._rotating_equipment + + def get_demand(self) -> EnergyStream: + demands = [eq.get_demand() for eq in self._rotating_equipment] + if not demands: + return EnergyStream.mechanical(0.0) + + expected_unit = demands[0].unit + for d in demands[1:]: + if d.unit != expected_unit: + raise ValueError(f"Inconsistent units in rotating equipment demands: {d.unit} vs {expected_unit}") + + total = sum(d.value for d in demands) + return EnergyStream.mechanical(total / self._mechanical_efficiency) diff --git a/src/libecalc/domain/energy/network/nodes/providers.py b/src/libecalc/domain/energy/network/nodes/providers.py new file mode 100644 index 0000000000..b4c9ddc25d --- /dev/null +++ b/src/libecalc/domain/energy/network/nodes/providers.py @@ -0,0 +1,112 @@ +import numpy as np + +from libecalc.domain.energy.infrastructure_contracts import FuelConverter, TurbineDriver +from libecalc.domain.energy.network.energy_node import EnergyNodeId, Provider, create_energy_node_id +from libecalc.domain.energy.network.energy_stream import EnergyStream, EnergyType + + +class ElectricMotor(Provider): + """Converts mechanical demand to electrical demand, adjusted for motor efficiency losses.""" + + def __init__(self, efficiency: float, max_power_mw: float): + self._id = create_energy_node_id() + self._efficiency = efficiency + self._max_power_mw = max_power_mw + + def get_id(self) -> EnergyNodeId: + return self._id + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.MECHANICAL: + raise ValueError(f"Electric motor expects mechanical demand, got {demand.energy_type}") + return EnergyStream.electrical(demand.value / self._efficiency) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Spare motor capacity in MW. Negative means demand exceeds motor rating.""" + return self._max_power_mw - demand.value + + +class Generator(Provider): + """Converts electrical demand to fuel consumption via a generator set.""" + + def __init__(self, generator: FuelConverter): + self._id = create_energy_node_id() + self._generator = generator + + def get_id(self) -> EnergyNodeId: + return self._id + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.ELECTRICAL: + raise ValueError(f"Generator expects electrical demand, got {demand.energy_type}") + return EnergyStream.fuel(self._generator.evaluate_fuel_usage(demand.value)) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Spare capacity in MW. Negative means demand exceeds what this generator can deliver.""" + return self._generator.evaluate_power_capacity_margin(demand.value) + + +class Turbine(Provider): + """Converts mechanical demand to fuel consumption via a gas turbine.""" + + def __init__(self, turbine: TurbineDriver): + self._id = create_energy_node_id() + self._turbine = turbine + + def get_id(self) -> EnergyNodeId: + return self._id + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.MECHANICAL: + raise ValueError(f"Turbine expects mechanical demand, got {demand.energy_type}") + result = self._turbine.evaluate(load=np.array([demand.value])) + energy_result = result.get_energy_result() + return EnergyStream.fuel(energy_result.energy_usage.values[0]) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Spare turbine capacity in MW. Negative means demand exceeds turbine rating.""" + return self._turbine.max_power - demand.value + + +class Shore(Provider): + """Power from shore: zero fuel, limited by cable capacity.""" + + def __init__(self, max_capacity_mw: float, cable_loss_fraction: float = 0.0): + self._id = create_energy_node_id() + self._max_capacity_mw = max_capacity_mw + self._cable_loss_fraction = cable_loss_fraction + + def get_id(self) -> EnergyNodeId: + return self._id + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.ELECTRICAL: + raise ValueError(f"Power from shore expects electrical demand, got {demand.energy_type}") + # Shore power produces zero fuel — the energy comes from the land grid. + return EnergyStream.fuel(0.0) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Spare cable capacity in MW. Negative means demand exceeds cable rating.""" + power_from_shore = demand.value * (1 + self._cable_loss_fraction) + return self._max_capacity_mw - power_from_shore + + +class Wind(Provider): + """Offshore wind: zero fuel, capacity set at construction.""" + + def __init__(self, available_power_mw: float): + self._id = create_energy_node_id() + self._available_power_mw = available_power_mw + + def get_id(self) -> EnergyNodeId: + return self._id + + def supply(self, demand: EnergyStream) -> EnergyStream: + if demand.energy_type != EnergyType.ELECTRICAL: + raise ValueError(f"Wind power expects electrical demand, got {demand.energy_type}") + # Wind produces zero fuel — energy comes from the turbines. + return EnergyStream.fuel(0.0) + + def get_capacity_margin(self, demand: EnergyStream) -> float: + """Spare wind capacity in MW. Negative means demand exceeds available wind power.""" + return self._available_power_mw - demand.value diff --git a/tests/libecalc/domain/energy/network/conftest.py b/tests/libecalc/domain/energy/network/conftest.py new file mode 100644 index 0000000000..dfd73748b0 --- /dev/null +++ b/tests/libecalc/domain/energy/network/conftest.py @@ -0,0 +1,18 @@ +from libecalc.domain.energy.infrastructure_contracts import FuelConverter + + +class SimpleFuelConverter(FuelConverter): + """Test stub: linear fuel consumption. + + fuel_per_mw: Sm³/day per MW (e.g. 100 means 1 MW costs 100 Sm³/day) + """ + + def __init__(self, fuel_per_mw: float, max_power_mw: float = float("inf")): + self._fuel_per_mw = fuel_per_mw + self._max_power_mw = max_power_mw + + def evaluate_fuel_usage(self, power_mw: float) -> float: + return power_mw * self._fuel_per_mw + + def evaluate_power_capacity_margin(self, power_mw: float) -> float: + return self._max_power_mw - power_mw diff --git a/tests/libecalc/domain/energy/network/test_energy_chain.py b/tests/libecalc/domain/energy/network/test_energy_chain.py new file mode 100644 index 0000000000..58f958752b --- /dev/null +++ b/tests/libecalc/domain/energy/network/test_energy_chain.py @@ -0,0 +1,186 @@ +"""End-to-end tests: Consumer → SerialEnergySystem → fuel.""" + +import pytest + +from libecalc.domain.energy.network.energy_stream import EnergyStream, EnergyType +from libecalc.domain.energy.network.energy_system import EnergyChain, PowerBus, SerialEnergySystem +from libecalc.domain.energy.network.nodes.consumers import RotatingEquipment, Shaft +from libecalc.domain.energy.network.nodes.providers import ElectricMotor, Generator, Shore +from libecalc.domain.infrastructure.energy_components.generator_set.generator_set_model import GeneratorSetModel +from libecalc.presentation.yaml.yaml_entities import MemoryResource +from tests.libecalc.domain.energy.network.conftest import SimpleFuelConverter + +FUEL_PER_MW = 100.0 # Sm³/day per MW + + +@pytest.fixture +def generator_model() -> GeneratorSetModel: + """Linear: 100 Sm³/day per MW, max 5 MW.""" + return GeneratorSetModel( + name="genset", + resource=MemoryResource(headers=["POWER", "FUEL"], data=[[0, 5], [0, 500]]), + ) + + +class TestSerialEnergySystem: + def test_motor_to_generator(self): + """1.5 MW mechanical → motor (100%) → 1.5 MW electrical → generator → fuel. + + fuel = 1.5 MW × 100 Sm³/day/MW = 150 Sm³/day + """ + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=1.0, max_power_mw=10.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)), + ] + ) + supply = system.supply(EnergyStream.mechanical(1.5)) + + assert supply.energy_type == EnergyType.FUEL + assert supply.value == 1.5 * FUEL_PER_MW + + def test_motor_efficiency_propagates(self): + """90% motor: 1.0 MW mechanical → 1.111 MW electrical → fuel. + + electrical = 1.0 / 0.90 = 1.111 MW + fuel = 1.111 × 100 = 111.1 Sm³/day + """ + demand = EnergyStream.mechanical(1.0) + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.9, max_power_mw=10.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)), + ] + ) + supply = system.supply(demand) + + assert supply.value == demand.value / 0.9 * FUEL_PER_MW + + def test_multiple_rotating_equipment(self): + """Shaft aggregates demand from multiple equipment. + + shaft: 1.5 + 0.5 = 2.0 MW mechanical + motor: 2.0 / 0.90 = 2.222 MW electrical + fuel: 2.222 × 100 = 222.2 Sm³/day + """ + shaft = Shaft( + rotating_equipment=[ + RotatingEquipment(demand=1.5), + RotatingEquipment(demand=0.5), + ] + ) + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.90, max_power_mw=10.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)), + ] + ) + + assert shaft.get_demand().value == 2.0 + supply = system.supply(shaft.get_demand()) + + assert supply.value == (2.0 / 0.90) * FUEL_PER_MW + + def test_capacity_margin(self): + """Tightest margin across the chain. + + demand: 1.0 MW mechanical + motor margin: 10.0 - 1.0 = 9.0 MW + electrical demand: 1.0 / 0.90 = 1.111 MW + generator margin: 2.0 - 1.111 = 0.889 MW (tightest) + system margin = min(9.0, 0.889) = 0.889 MW + """ + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.90, max_power_mw=10.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW, max_power_mw=2.0)), + ] + ) + demand = EnergyStream.mechanical(1.0) + + assert system.get_capacity_margin(demand) == 2.0 - demand.value / 0.90 + + def test_shore_and_generator_split(self): + """Shore covers 1 MW (zero fuel), generator covers rest. + + shaft: 2.0 MW mechanical + motor: 2.0 / 0.90 = 2.222 MW electrical + shore: 1.0 MW → 0 Sm³/day + generator: 2.222 - 1.0 = 1.222 MW → 1.222 × 100 = 122.2 Sm³/day + """ + shaft = Shaft(rotating_equipment=[RotatingEquipment(demand=2.0)]) + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.90, max_power_mw=10.0), + PowerBus( + sources=[ + Shore(max_capacity_mw=1.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)), + ] + ), + ] + ) + supply = system.supply(shaft.get_demand()) + + electrical_demand = 2.0 / 0.90 + generator_load = electrical_demand - 1.0 + assert supply.value == generator_load * FUEL_PER_MW + + def test_with_real_generator_set_model(self, generator_model): + """End-to-end with interpolation-based GeneratorSetModel. + + shaft: 1.5 MW mechanical + motor (95%): 1.5 / 0.95 = 1.579 MW electrical + generator: 1.579 × 100 = 157.9 Sm³/day (from [0,5] MW → [0,500] Sm³/day table) + """ + shaft = Shaft(rotating_equipment=[RotatingEquipment(demand=1.5)]) + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.95, max_power_mw=10.0), + Generator(generator=generator_model), + ] + ) + supply = system.supply(shaft.get_demand()) + + assert supply.value == (1.5 / 0.95) * FUEL_PER_MW + + +class TestEnergyChain: + def test_run(self): + """Full chain: shaft → motor → generator → fuel + capacity margin. + + shaft: 1.0 / 0.95 = 1.053 MW mechanical + motor: 1.053 / 0.90 = 1.170 MW electrical + fuel: 1.170 × 100 = 116.96 Sm³/day + """ + demand = 1.0 + shaft = Shaft(rotating_equipment=[RotatingEquipment(demand=demand)], mechanical_efficiency=0.95) + system = SerialEnergySystem( + nodes=[ + ElectricMotor(efficiency=0.90, max_power_mw=10.0), + Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)), + ] + ) + chain = EnergyChain(consumer=shaft, supply_chain=system) + + result = chain.run() + + assert result.fuel.value == (demand / 0.95 / 0.90) * FUEL_PER_MW + assert result.fuel.energy_type == EnergyType.FUEL + assert result.demand.value == demand / 0.95 + assert result.demand.energy_type == EnergyType.MECHANICAL + assert result.capacity_margin_mw == 10.0 - demand / 0.95 + + def test_get_all_nodes(self): + """EnergyChain.get_all_nodes() returns consumer + all supply chain children.""" + shaft = Shaft(rotating_equipment=[RotatingEquipment(demand=1.0)]) + motor = ElectricMotor(efficiency=0.90, max_power_mw=10.0) + generator = Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)) + system = SerialEnergySystem(nodes=[motor, generator]) + chain = EnergyChain(consumer=shaft, supply_chain=system) + + all_nodes = chain.get_all_nodes() + + assert all_nodes[0] is shaft + assert motor in all_nodes + assert generator in all_nodes diff --git a/tests/libecalc/domain/energy/network/test_nodes.py b/tests/libecalc/domain/energy/network/test_nodes.py new file mode 100644 index 0000000000..99b77f2d1a --- /dev/null +++ b/tests/libecalc/domain/energy/network/test_nodes.py @@ -0,0 +1,136 @@ +"""Unit tests for individual energy nodes (consumers and providers).""" + +import pytest + +from libecalc.domain.energy.network.energy_stream import EnergyStream, EnergyType +from libecalc.domain.energy.network.nodes.consumers import RotatingEquipment, Shaft +from libecalc.domain.energy.network.nodes.providers import ElectricMotor, Generator, Shore, Wind +from libecalc.domain.infrastructure.energy_components.generator_set.generator_set_model import GeneratorSetModel +from libecalc.presentation.yaml.yaml_entities import MemoryResource +from tests.libecalc.domain.energy.network.conftest import SimpleFuelConverter + +FUEL_PER_MW = 100.0 # Sm³/day per MW + + +class TestRotatingEquipment: + def test_demand(self): + eq = RotatingEquipment(demand=0.5) + stream = eq.get_demand() + assert stream.value == 0.5 + assert stream.energy_type == EnergyType.MECHANICAL + + def test_zero_demand(self): + eq = RotatingEquipment(demand=0.0) + assert eq.get_demand().value == 0.0 + + +class TestShaft: + def test_aggregates_demand(self): + shaft = Shaft( + rotating_equipment=[RotatingEquipment(demand=0.5), RotatingEquipment(demand=1.0)], + ) + assert shaft.get_demand().value == 1.5 + + def test_mechanical_efficiency(self): + """0.5 MW through 50% efficient shaft → 1.0 MW demand.""" + shaft = Shaft( + rotating_equipment=[RotatingEquipment(demand=0.5)], + mechanical_efficiency=0.5, + ) + assert shaft.get_demand().value == 1.0 + + +class TestElectricMotor: + def test_type_conversion(self): + """Mechanical demand → electrical output.""" + demand = EnergyStream.mechanical(1.0) + motor = ElectricMotor(efficiency=1.0, max_power_mw=10.0) + supply = motor.supply(demand) + assert supply.energy_type == EnergyType.ELECTRICAL + assert supply.value == demand.value + + def test_efficiency_loss(self): + """90% motor efficiency: 1.0 MW mechanical → 1.111 MW electrical.""" + demand = EnergyStream.mechanical(1.0) + motor = ElectricMotor(efficiency=0.9, max_power_mw=10.0) + supply = motor.supply(demand) + assert supply.value == demand.value / 0.9 + + def test_capacity_margin(self): + motor = ElectricMotor(efficiency=0.9, max_power_mw=5.0) + demand = EnergyStream.mechanical(1.0) + assert motor.get_capacity_margin(demand) == 4.0 + + +class TestGenerator: + def test_fuel_from_power(self): + """1.5 MW electrical → 150 Sm³/day fuel.""" + demand = EnergyStream.electrical(1.5) + gen = Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW)) + supply = gen.supply(demand) + assert supply.energy_type == EnergyType.FUEL + assert supply.value == demand.value * FUEL_PER_MW + + def test_capacity_margin(self): + """Generator capacity: 2.0 MW, demand: 1.5 MW → margin = 0.5 MW.""" + gen = Generator(generator=SimpleFuelConverter(fuel_per_mw=FUEL_PER_MW, max_power_mw=2.0)) + demand = EnergyStream.electrical(1.5) + assert gen.get_capacity_margin(demand) == 2.0 - demand.value + + +class TestGeneratorWithGeneratorSetModel: + """Tests using the real GeneratorSetModel (interpolation-based).""" + + @pytest.fixture + def generator_model(self) -> GeneratorSetModel: + """Linear: 100 Sm³/day per MW, max 5 MW.""" + return GeneratorSetModel( + name="test_genset", + resource=MemoryResource(headers=["POWER", "FUEL"], data=[[0, 5], [0, 500]]), + ) + + def test_fuel_from_power(self, generator_model): + demand = EnergyStream.electrical(1.5) + gen = Generator(generator=generator_model) + supply = gen.supply(demand) + assert supply.value == demand.value * 100.0 # 100 Sm³/day per MW + + def test_capacity_margin(self, generator_model): + demand = EnergyStream.electrical(1.5) + gen = Generator(generator=generator_model) + assert gen.get_capacity_margin(demand) == 5 - demand.value # Max power is 5 MW + + +class TestShore: + def test_zero_fuel(self): + demand = EnergyStream.electrical(3.0) + supply = Shore(max_capacity_mw=10.0).supply(demand) + assert supply.value == 0.0 + assert supply.energy_type == EnergyType.FUEL + + def test_capacity_margin(self): + demand = EnergyStream.electrical(3.0) + shore = Shore(max_capacity_mw=10.0) + assert shore.get_capacity_margin(demand) == 10 - demand.value + + def test_cable_loss(self): + """5% cable loss → 3 MW requires 3.15 MW from shore.""" + demand = EnergyStream.electrical(3.0) + shore = Shore(max_capacity_mw=10.0, cable_loss_fraction=0.05) + assert shore.get_capacity_margin(demand) == 10.0 - demand.value * 1.05 + + def test_exceeds_capacity(self): + shore = Shore(max_capacity_mw=2.0) + assert shore.get_capacity_margin(EnergyStream.electrical(3.0)) < 0 + + +class TestWind: + def test_zero_fuel(self): + demand = EnergyStream.electrical(2.0) + supply = Wind(available_power_mw=5.0).supply(demand) + assert supply.value == 0.0 + + def test_capacity_margin(self): + demand = EnergyStream.electrical(2.0) + wind = Wind(available_power_mw=5.0) + assert wind.get_capacity_margin(demand) == 5.0 - demand.value diff --git a/tests/libecalc/domain/energy/network/test_power_bus.py b/tests/libecalc/domain/energy/network/test_power_bus.py new file mode 100644 index 0000000000..e3c84ad40d --- /dev/null +++ b/tests/libecalc/domain/energy/network/test_power_bus.py @@ -0,0 +1,72 @@ +"""PowerBus distributes electrical demand across sources by priority.""" + +from libecalc.domain.energy.network.energy_stream import EnergyStream, EnergyType +from libecalc.domain.energy.network.energy_system import PowerBus +from libecalc.domain.energy.network.nodes.providers import Generator, Shore +from libecalc.domain.infrastructure.energy_components.generator_set.generator_set_model import GeneratorSetModel +from libecalc.presentation.yaml.yaml_entities import MemoryResource + +FUEL_PER_MW = 100.0 # Sm³/day per MW, from linear generator curve + + +def _make_generator(max_mw: float) -> Generator: + """Linear fuel curve: 100 Sm³/day per MW.""" + return Generator( + generator=GeneratorSetModel( + name=f"genset_{max_mw}", + resource=MemoryResource( + headers=["POWER", "FUEL"], + data=[[0, max_mw], [0, max_mw * FUEL_PER_MW]], + ), + ) + ) + + +class TestPowerBus: + def test_single_source(self): + """3 MW demand, single 10 MW generator → 300 Sm³/day fuel.""" + demand = EnergyStream.electrical(3.0) + result = PowerBus(sources=[_make_generator(10.0)]).supply(demand) + assert result.energy_type == EnergyType.FUEL + assert result.value == demand.value * FUEL_PER_MW + + def test_priority_order(self): + """3 MW demand, two generators — first source covers all demand.""" + demand = EnergyStream.electrical(3.0) + supply = PowerBus( + sources=[ + _make_generator(10.0), + _make_generator(10.0), + ] + ).supply(demand) + assert supply.value == demand.value * FUEL_PER_MW + + def test_spillover_to_second_source(self): + """3 MW demand, first generator max 2 MW → 1 MW spills to second. + + generator 1: 2 MW × 100 = 200 Sm³/day + generator 2: 1 MW × 100 = 100 Sm³/day + """ + demand = EnergyStream.electrical(3.0) + supply = PowerBus( + sources=[ + _make_generator(2.0), + _make_generator(5.0), + ] + ).supply(demand) + assert supply.value == demand.value * FUEL_PER_MW + + def test_generator_plus_shore(self): + """3 MW demand, generator max 2 MW, shore backup. + + generator: 2 MW × 100 = 200 Sm³/day + shore: 1 MW → 0 Sm³/day + """ + demand = EnergyStream.electrical(3.0) + supply = PowerBus( + sources=[ + _make_generator(2.0), + Shore(max_capacity_mw=10.0), + ] + ).supply(demand) + assert supply.value == (demand.value - 1) * FUEL_PER_MW # Shore covers 1 MW with zero fuel.